Method Fossilisation in 2026
This is an updated version
Method fossilisation happens when methods get harder to update, due to sprawling overwriting. It is a process, more than a final state. In particular, it happens to methods when the signature is shared across multiple classes, via inheritance and interfaces. As their number grows, they render part of the code unmodifiable, without refactoring large number of files.
Let’s see how this happens.
Single methods
Single methods are methods that are not overridden by any child class and are not present in any parent class. Such methods can be updated freely: the impact is local only, and at call sites: this is quite straightforward. They do not get fossilized.
<?php
class x {
// target : function method(int $a) {
function method($a) {
// simple method, isn't it?
return $a + 1;
}
}
?>
Method fossilisation
When a method is overridden, one or multiple times across an inheritance hierarchy, the signature becomes fossilised. In practice, a single project can have methods with up to dozens of versions across the hierarchy. Changing one of them requires coordinated refactoring in as many locations.
This situation typically emerges from historical layered code or component strategies based on a shared template or interface.
In the illustration, changing method C2 in a deep hierarchy can cascade into six required edits across the application, each one a potential source of regression.
Inherited methods
Inherited methods exist in a parent and a child class. Sometimes, there are grand-parents, and siblings, and then cousins: this is would be just more complex, without adding value to this illustration.
Their signatures must be compatible: they share a common signature constraint.
<?php
class x {
function method($a) {
// simple method, isn't it?
return $a + 1;
}
}
class y extends x {
function method($b) {
// overwritten method, with distinct code
return $b + 2;
}
}
?>
The methods don’t have to be absolutely identical, so there is still some room to update them independently. Yet, there are rules about the number, the visibility, the names, and the types of the arguments.
Visibility
Let’s start with method visibility. The visibility of a method can only be from private to protected to public, and it will not go back. It may also stay at the level of visibility.
<?php
class foo {
private function method() {}
}
class bar extends foo {
public function method() {}
}
?>
Usually, visibility is not changed at extension time.
Asymmetric visibility does not apply to methods, so there is nothing to check for that feature.
Number of arguments
The simple rule is that x::method() may be called just like y::method(). This means:
- The number of compulsory arguments, which are those without default values, cannot change between parent and child.
- Extra optional arguments, with default values, may be added.
<?php
class x {
// one compulsory argument
function method($a) {
return $a + 1;
}
}
class y extends x {
// one compulsory argument, one optional argument
function method($b, $increment = 2) {
return $b + $increment;
}
}
?>
Constructors are exempt from these constraints and may change signatures freely between parent and child classes.
<?php
class foo {
function __construct() {}
}
class bar extends foo {
function __construct($a, $b) {}
}
?>
Argument names
Arguments names may change from one method to its child method. PHP doesn’t enforce identical argument names between parent and child methods.
<?php
class X {
function method($a) {
// one argument
return $a + 1;
}
}
class Y extends X {
function method($b) {
// overwritten method, with distinct parameter name
return $b + 2;
}
}
echo (new Y)->method(b: 1);
?>
And yet, as of PHP 8.0, argument names are part of the public API surface. Since they are not enforced, the last class overwrite all the previous definitions.
<?php
(new Y)->method(b: 4.99);
?>
This means that renaming $a to $b in any class in the hierarchy silently breaks every caller using named argument syntax. PHP itself does not emit an error at the method definition level: the error surfaces at call sites, potentially in user code you do not control.
This is a third axis of fossilisation on top of arity, order, and types. A method may fossilise its parameter names too.
Note that using interfaces does not add any extra verifications to the method signatures, at the name level.
<?php
interface i {
function method($z);
}
class x implements i {
function method($a) {
// one argument
return $a + 1;
}
}
class y extends x {
function method($b) {
// overwritten method, with distinct parameter name
return $b + 2;
}
}
echo (new y)->method(b: 1);
?>
Types fossilisation
PHP checks types for compatibility between a family of classes. The rule is based on variance:
- Return typehints are covariant: a child may return a more specific type than its parent.
- Argument typehints are contravariant: a child may accept a more general type than its parent.
This was formalised in PHP 7.4 and is still applied in PHP 8.5.
Adopting typehints incrementally
Adopting types incrementally across fossilised methods can create inconsistencies: one class in the hierarchy may have a type while others do not, making the overall contract ambiguous.
<?php
class foo {
function method(int $a) {}
}
class bar extends foo {
function method($a): int {}
}
?>
Scalar cul-de-sac
Scalar typehints, such as int, float, string, bool, are usually the first types adopted, but they are the hardest to refactor. Scalar types have no subtype relationship in PHP’s type system, so variance cannot help evolve a float parameter into a class type: every class in the hierarchy must change at the same time.
<?php
class foo {
function method(float $a): y {}
}
class bar extends foo {
function method(float $a): x {}
}
?>
Union and intersection types: some wiggle room
PHP 8.0 union types types do not escape the scalar cul-de-sac entirely, but they give more incremental room than the original situation.
Widening a parameter type with contravariance: a child method may accept a broader type than its parent. A parent typed int can be overridden with int|float in the child, because int|float is more permissive.
<?php
class foo {
function method(int $a) {}
}
class bar extends foo {
// Valid: int|float is broader than int
function method(int|float $a) {}
}
This allows progressively loosen parameter types downward through the hierarchy, one class at a time, without breaking compatibility.
Narrowing a return type, with covariance: a child method may return a stricter type than its parent. A parent returning int|float can be overridden with a child returning int.
<?php
class foo {
function method(): int|float {}
}
class bar extends foo {
// Valid: int is narrower than int|float
function method(): int {}
}
You still cannot directly move a float parameter to a class type Price in a single refactoring step. Union types let you introduce float|Price as a transitional stage, preserving backward compatibility while you migrate callers, and then later drop the float leg.
That second step still requires a coordinated all-at-once change across the whole hierarchy. Union types turn a one-step cliff into a two-step slope, which is a real improvement. The cost of this approach is a bit of confusion, and a long time to propagate the transformations.
Intersection types, since PHP 8.1, follow the same covariance/contravariance rules. They are most useful for refining interface combinations in return positions: a parent returning Countable can be overridden returning Countable&Stringable, narrowing the contract without breaking it.
Interface types
Fossilised methods are most flexible with interface-style types and abstract classes, because variance applies fully: a parameter typed to an interface can be overridden to accept any ancestor interface, and a return typed to an interface can be overridden to return any implementing class.
<?php
interface i {
function method(int $a);
}
class foo implements i {
public function method(int|float $a) {}
}
?>
Using interfaces instead of scalars from the start is the best long-term investment for large class families.
The same union and intersection types tricks apply to interfaces.
<?php
interface i {
function method(int $a);
}
class foo implements i {
public function method(int|float $a) {}
}
?>
Modern PHP features and fossilisation
PHP 8 introduced several features that interact with inheritance hierarchies. None of them fundamentally change the fossilisation dynamic, but each adds a surface that deserves attention when auditing a fossilised method.
never return type (PHP 8.1)
never is the ultimate covariant return type: a method returning never always throws or exits and never produces a value. Any child class method must also return never.
<?php
abstract class Base {
function unsupported(): never {
throw new \BadMethodCallException();
}
}
class Child extends Base {
function unsupported(): string {
// overwritten, but still throwing exceptions
throw new \BadMethodCall2Exception();
}
}
?>
Readonly properties (PHP 8.1) and readonly classes (PHP 8.2)
readonly properties are only defined in the constructor, as a promoted property. Constructors are exempted from compatibility with parents, so this does not impact fossilisation.
Property hooks (PHP 8.4)
Property hooks allow get and set logic to be attached directly to a property, effectively embedding method-like behaviour in the property declaration.
In an inheritance hierarchy, a hooked property in a parent can be overridden in a child. There are two tricks at play here: first, the set method may have a type, and it only have to be compatible with the type of the property. This makes sense for the default usage: the set method of a float property must accept a float.
<?php
class foo {
public float $price {
set(float $v) { $this->rawPrice = $v; }
}
}
class bar extends foo {
// Overriding the hook is allowed, but must remain compatible.
public float $price {
set(float|int $v) { $this->rawPrice = $v; }
}
}
?>
Here, the type of the set hook stays compatible with the property, as the union type is an extension of the float type of the property. This is legit code for PHP, and actually useful: one may accept a different type, and convert it before assiging it to the property itself.
Now, once a union type has been added to the hook, it must stay compatible with that type in the child classes. In the example below, the set in the bar class must accept float|int, since the set in the foo class did.
<?php
class foo {
public float $price {
set(float|string $v) { $this->rawPrice = (float) $v; }
}
}
class bar extends foo {
// Overriding the hook is allowed, but must remain compatible.
public float $price {
set(float|int|string|null $v) { $this->rawPrice = (float) $v; }
}
}
?>
Avoiding method fossilisation
- Monitor fossilisation. Keep a report to check when methods are overriden too many times and avoid building up large clusters of such methods. While there is no industry threshold available, the higher the number of overridden methods, the higher the risk of fossilisation.
- Limit uage scalar typehints in large class families. Prefer interface-style types; use union types as a transitional bridge when migrating away from scalars, when you have the patience for it.
- Treat parameter names as API. Since PHP 8.0, named arguments make parameter names part of the public contract. Ensure that the same name is used in parent and child classes.
- Document compatibility breaks. Mention signature changes explicitly in changelogs and reserve breaking changes for major versions.
- Consider skipping inheritance. Composition and delegation eliminate fossilisation entirely, at the cost of a different kind of indirection.
Method fossilisation
Method fossilisation comes along with class hierarchies. Either the code keeps these families in check with low number of extends and emphasis on composition, or there will be clumps of code that are created and cannot be changed without major impact.


