Table of Contents

PHP RFC: Sealed Classes

Introduction

The purpose of inheritance is code reuse, for when you have a class that shares common functionality, and you want others to be able to extend it and make use of this functionality in their own class.

However, when you have a class in your code base that shares some implementation detail between 2 or more other objects, your only protection against others making use of this class is to add `@internal` annotation, which doesn't offer any runtime guarantee that no one is extending this object.

Internally, PHP has the `Throwable` interface, which defines common functionality between `Error` and `Exception` and is implemented by both, however, end users are not allowed to implement `Throwable`.

Currently PHP has a special case for `Throwable` ( and `DateTimeInterface` ), this RFC is proposing to make this kind of functionally possible to user land.

Proposal

Support for sealed classes is added through a new modifier `sealed`, and a new `permits` clause that takes place after `extends`, and `implements`.

A class that is sealed can be extended directly only by the classes named in the attribute value list. Similarly, an interface, or a trait that is sealed can be implemented, or used directly only by the classes named in the permit clause value list. Classes named in the permit clause value list can themselves be extended arbitrarily unless they are final or also sealed. In this way, sealing provides a single-level restraint on inheritance.

sealed interface Option permits Some, None { ... }
 
interface Some extends Option { ... } // ok
interface None extends Option { ... } // ok
 
interface Maybe extends Option { ... } // Fatal error: Interface Maybe cannot extend sealed interface Option.

An interface that is sealed can be implemented directly only by the classes named in the `permits` clause.

namespace Psl\Result {
  sealed interface ResultInterface permits Success, Failure { ... }
 
  class Success implements ResultInterface { ... }
  class Failure implements ResultInterface { ... }
 
  function wrap(callable $callback): ResultInterface { ... }
 
  function unwrap(ResultInterface $result): mixed
  {    
    return match(true) {
      $result instanceof Success => $result->value(),
      $result instanceof Failure => throw $result->error(),
    }; // no need for default, it's not possible.
  }
 
}
 
namespace App {
  use Psl\Result;
 
  // Fatal error: Class App\Maybe cannot implement sealed interface Psl\Result\ResultInterface.
  final class Maybe implements Result\ResultInterface {}
}

Similarly, a trait that is sealed can only be used by the classes named in the `permits` clause.

This is an example taken from the PSL Channel component

namespace Psl\Channel\Internal {
  use Psl\Channel\ReceiverInterface;
 
  sealed trait ChannelSideTrait permits BoundedReceiver, BoundedSender, UnboundedReceiver, UnboundedSender { ... }
 
  // OK
  final class BoundedReceiver implements ReceiverInterface
  {
    use ChannelSideTrait;
 
    ...
  }
 
  // OK
  final class UnboundedReceiver implements ReceiverInterface
  {
    use ChannelSideTrait;
 
    ...
  }
}
 
namespace App\Channel {
    use Psl\Channel\Internal\ChannelSideTrait;
 
    // Fatal error: Class App\Channel\MyReceiver may not use sealed trait Psl\Channel\Internal\ChannelSideTrait.
    final class MyReceiver {
      use ChannelSideTrait;
 
      ...
    }
 
    // Fatal error: Trait App\Channel\MyReceiver may not use sealed trait Psl\Channel\Internal\ChannelSideTrait.
    trait MyChannelSideTrait {
      use ChannelSideTrait;
 
      ...
    }
}

When a sealed class, trait, or an interface permits a specific interface, any class can use it as long as that interface exists in it's inheritance tree, as follows:

This also applies when sealing to a specific class, or a trait.

interface Foo {}
 
sealed interface Bar permits Foo {}
 
class FooImpl implements Foo {}
 
class Baz extends FooImpl {}
 
// `BarImpl` is allowed to implement `Bar` because it extends `Baz`, which
// extends `FooImpl`, which implement `Foo`, making `BarImpl` an instance of `Foo`.
class BarImpl extends Baz implements Bar {}

Sealed classes with no `permits` clause, or with an empty `permits` clause will result in a parse error, as follows:

// Parse error: syntax error, unexpected '{', expecting permits (T_PERMITS) in ...
sealed class Bar { }
 
// Parse error: syntax error, unexpected '{', expecting identifier (T_STRING) or namespace (T_NAMESPACE) or \\ (T_NS_SEPARATOR) in ...
sealed class Bar permits { }

Why not composite type aliases

Some have suggested that the use of composite type aliases could solve the same problem, such as:

<?php
 
final class Success { ... }
final class Failure { ... }
 
type Result = Success | Failure;
 
function foo(): Result { ... }
 
$result = foo();
if ($result instanceof Success) {
  // $result is Success
} else {
  // $result is Failure
}

However, a type alias of N types, is not the same as a sealed class that permits N sub-types, as sealing offers 2 major differences.

1. shared functionality

Sealed classes feature allow you to implement functionalities in the parent class, such as:

<?php

sealed abstract class Result permits Success, Failure
{
  public function then(Closure $success, Closure $failure): Result
  {
      try {
        $result = $this instanceof Success ? $success($this->value) : $failure($this->throwable);

        return new Success($result);
      } catch(Throwable $e) {
        return new Failure($e);
      }
  }

  public function catch(Closure $failure): Result
  {
    return $this->then(fn($value) => $value, $failure);
  }

  public function map(Closure $success): Result
  {
    return $this->then(
      $success,
      fn($exception) => throw $exception
    );
  }
}

final class Success extends Result {
  public function __construct(
    public readonly mixed $value,
  ) {}
}

final class Failure extends Result {
  public function __construct(
    public readonly Throwable $throwable,
  ) {}
}

2. the N+1 type.

Unlike a type alias, a sealed class is by itself a type.

Considering the following code which uses type aliases:

final class B {}
final class C {}
 
type A = B|C;

When you have a function defined as:

function consumer(A $instance): void
{
  echo $instance::class;
}

The output can only be either `“B”` or `“C”`.

However, considering the following code which uses sealed classes feature:

sealed class A permits B, C {}
final class B extends A {}
final class C extends A {}

The output of `consumer` could be either `“A”`, `“B”`, or `“C”`, as `A` is a non-abstract class, it is possible to do `consumer(new A())`.

Syntax

Some people might be against introducing a new keywords into the language, which will lead to `sealed` and `permits` not being a valid class names anymore, therefor, a second vote will take place to decide which syntax should be used.

The available options are the following:

1. using `sealed`+`permits`:

sealed class Foo permits Bar, Baz {}
 
sealed interface Qux permits Quux, Quuz {}
 
sealed trait Corge permits Grault, Garply {}

2. using `permits` only:

class Foo permits Bar, Baz {}
 
interface Qux permits Quux, Quuz {}
 
trait Corge permits Grault, Garply {}

3. using pre-reserved `for` keyword:

class Foo for Bar, Baz {}
 
interface Qux for Quux, Quuz {}
 
trait Corge for Grault, Garply {}

FAQ's

Wouldn't a sealed class without permits clauses be considered final?

No, a sealed class will always have a `permits` clauses, if a sealed class is defined without a `permits` clauses, it's considered a compile error.

Would PHP check if permitted classes exists when loading a sealed class?

No, when loading a sealed class, PHP would treat just like any other class, and store the permitted types list to check against later when another type tries to inherit from it.

What if the permitted types don't actually extend the sealed type

Example:

sealed interface A permits B {}
 
class B {}

This code would not produce any errors, as another type ( e.g: `C` ) could exist, in which it inherits from both `B`, and `A`, therefor, an instance of `A&B` could still exist.

What if the permitted types don't actually extend the sealed type, and are final

Example:

sealed interface A permits B {}
 
final class B {}

In this case, we would end up with an interface 'A', but with no possible instance of it, however, due to the behavior stated above of only checking permitted types on inheritance and not when loading the sealed type, this is allowed, and is considered a small inconvenience.

Backward Incompatible Changes

`sealed` and `permits` become reserved keywords in PHP 8.2

Proposed PHP Version(s)

PHP 8.2

RFC Impact

To Opcache

TBD

To Reflection

The following additions will be made to expose the new flag via reflection:

Vote

As this is a language change, a 2/3 majority is required.

Voting started on 2022-03-17 and will end on 2022-03-31.

Accept sealed classes RFC?
Real name Yes No
asgrim (asgrim)  
ashnazg (ashnazg)  
beberlei (beberlei)  
brzuchal (brzuchal)  
crell (crell)  
danack (danack)  
derick (derick)  
diegopires (diegopires)  
galvao (galvao)  
heiglandreas (heiglandreas)  
irker (irker)  
kalle (kalle)  
kocsismate (kocsismate)  
mcmic (mcmic)  
nicolasgrekas (nicolasgrekas)  
ocramius (ocramius)  
patrickallaert (patrickallaert)  
pollita (pollita)  
ramsey (ramsey)  
santiagolizardo (santiagolizardo)  
sergey (sergey)  
stas (stas)  
svpernova09 (svpernova09)  
thekid (thekid)  
theodorejb (theodorejb)  
weierophinney (weierophinney)  
zimt (zimt)  
Final result: 16 11
This poll has been closed.

A Second vote for the preferred syntax choice.

Which syntax option do you prefer?
Real name `sealed` + `permits` `permits` only `for`
asgrim (asgrim)   
ashnazg (ashnazg)   
brzuchal (brzuchal)   
bwoebi (bwoebi)   
crell (crell)   
cschneid (cschneid)   
danack (danack)   
derick (derick)   
diegopires (diegopires)   
galvao (galvao)   
heiglandreas (heiglandreas)   
ilutov (ilutov)   
irker (irker)   
kalle (kalle)   
marandall (marandall)   
mcmic (mcmic)   
nicolasgrekas (nicolasgrekas)   
ocramius (ocramius)   
petk (petk)   
pollita (pollita)   
ramsey (ramsey)   
santiagolizardo (santiagolizardo)   
sergey (sergey)   
svpernova09 (svpernova09)   
thekid (thekid)   
theodorejb (theodorejb)   
weierophinney (weierophinney)   
Final result: 12 15 0
This poll has been closed.

Patches and Tests

Prototype patch using `for` syntax: https://github.com/php/php-src/compare/master...azjezz:sealed-classes

References

Changelog