Skip to article
← Back to Blog

PHP, on a Whim #3: Who Is Allowed to Implement This?

Interfaces are open by default, and that is usually a good thing.

If I publish an interface like this:

interface Logger {
  public function log(string $message): void;
}

anyone may implement it.

A library can provide one logger, an application can provide another, and some poor bastard can write a logger that sends every message to the office printer if that is what the business requires.

The interface describes a capability. It does not own every possible implementation of that capability.

Some interfaces are different.

Consider the outline of an option type:

interface Option<out T> {}

final readonly class Some<out T> implements Option<T> {}
final class None implements Option<never> {}

The library is not trying to create an extension point where applications invent their own meanings for an optional value. The family is supposed to be Some or None.

Without another language feature, though, nothing prevents this:

final class MaybeLater<T> implements Option<T> {}

Good luck to every match and every assumption in the option library.

An ordinary interface says what an implementation must provide. It does not say who may provide it.

Whim can say both:

interface Option<out T> for Some, None {}

Only Some and None may directly extend or implement Option.

Whim calls this a sealed family. The for list names its permitted direct children.

Open contracts and owned families#

An open interface makes sense when unrelated code should be able to participate. Loggers, iterators, serializers, clocks, and event listeners often benefit from that. A framework or a library should not need to know every implementation that will ever exist.

Other interfaces describe a family that the library owns:

interface Result<out T, out E> for Ok, Err {}

Whim's standard library uses this for both Option and Result. Their implementations are final: an option is either Some or None, and a result is either Ok or Err.

Whim also uses sealing throughout reflection. For example, ClassLikeReflection has three permitted implementations:

interface ClassLikeReflection extends SymbolReflection, GenericDeclarationReflection
  for ClassReflection, InterfaceReflection, EnumReflection {}

The runtime produces those reflection values. User code should not be able to invent a fourth kind of class-like reflection object and claim that it describes a declaration the engine knows about.

The restriction belongs in the declaration. A note asking people not to implement the interface would not enforce it.

for also works on classes#

Classes can own a family too:

abstract class AuthenticationEvent for Login, Logout {}

final class Login extends AuthenticationEvent {}
final class Logout extends AuthenticationEvent {}

Any other direct child fails:

final class PasswordChanged extends AuthenticationEvent {}

final allows no children. A for list allows specific children.

That gives an abstract base class a way to share code among known variants without letting unrelated code extend it.

The rule is about direct children#

The important word is direct.

Consider this family:

interface Vehicle for Motorized, Towed {}

interface Motorized extends Vehicle for Car {}
interface Towed extends Vehicle for Trailer {}

final class Car implements Motorized {}
final class Trailer implements Towed {}
  • Only Motorized and Towed may directly extend or implement Vehicle.
  • Only Car may directly extend or implement Motorized.
  • Only Trailer may directly extend or implement Towed.

Car is still a Vehicle. It enters the family through the permitted Motorized branch.

Each declaration controls its immediate children. That lets a family grow in parts without making the root list every descendant.

It also means sealing does not necessarily give you a fixed set of concrete classes:

abstract class AuthenticationEvent for Login {}

class Login extends AuthenticationEvent {}

final class DetailedLogin extends Login {}

DetailedLogin is an AuthenticationEvent too. It extends Login, which is neither final nor sealed.

The for Login list restricts direct inheritance from AuthenticationEvent. It places no new restriction on inheritance from Login.

If you want a fully closed family, each branch must eventually end in a final class or another sealed declaration whose own branches close in the same way. Leaving a branch open is allowed, but it is a choice.

Why not just use a union?#

For some code, a union is enough:

type Option<T> = Some<T>|None;

That says which values the type accepts. It does not create a shared parent or impose a common contract on those classes.

A sealed interface can declare methods, properties, constants, type parameters, and default implementations:

interface Outcome for Success, Failure {
  public readonly string $message;

  public function succeeded(): bool;

  public function failed(): bool {
    return !$this->succeeded();
  }
}

Code can accept that contract:

function report(Outcome $outcome): string {
  return match ($outcome->failed()) {
    true => 'failed: ' . $outcome->message,
    false => 'succeeded: ' . $outcome->message,
  };
}

Every implementation must meet the interface's requirements, and every implementation inherits its default behavior unless it overrides it.

A union can group existing types. A sealed interface can define what its family must provide and who may join it. Which one you want depends on the job.

This does not make matching exhaustive yet#

A closed family gives a compiler useful information for pattern matching.

For the standard library's option type, this covers both variants:

use Whim\Option\{Option, Some, None};

function unwrap_or_null<T>(Option<T> $option): T|null {
  return match ($option) {
    Some<T> #{ $value } => $value,
    None => null,
  };
}

Whim does not currently use sealing to prove match exhaustiveness. It accepts matches without that proof and checks at runtime whether an arm handles the value.

For now, sealing restricts inheritance and makes the permitted names available through reflection. It could later help with exhaustiveness checks, optimization, generated visitors, or tooling. Those are possible uses, not promises about what Whim does today.

I want to be clear about that because discussions of sealed types often jump straight to pattern matching. Enforcing the family's membership is useful on its own. A library can rely on its known implementations even before the compiler uses that knowledge elsewhere.

The children do not need to exist yet#

Whim supports runtime loading and autoloading. Requiring every permitted child to load before its parent would be a pain.

So this declaration does not load Some or None:

interface Option<out T> for Some, None {}

It stores their fully qualified names.

When a declaration later tries to extend or implement Option, Whim checks that declaration's name against the list. This child is permitted:

final class Some<T> implements Option<T> {}

This one is not:

final class Maybe<T> implements Option<T> {}

The list grants permission. It does not force a listed type to exist or to use that permission.

The same rule applies across files and through the autoloader. Loading an unrelated implementation later does not evade the restriction.

How Whim checks it#

The parser reads the optional for clause on a class or interface. The compiler resolves the listed names in the declaration's namespace and stores them in its metadata. Resolving a name here does not mean loading the named type.

If the relevant parent and child declarations are in the same compilation unit, the compiler can reject a violation there.

The linker also checks the relationship, which covers declarations loaded from other units. The runtime retains the list as sealed_to metadata.

Conceptually, the check is small:

if the parent has a permitted list
  and the child's name is not in that list
then reject the relationship

Whim applies that check to the direct parent class and each directly implemented or extended interface. A declaration with several sealed bases must have permission from each one.

The names must match exactly. If Vehicle permits Motorized, the declaration directly extending Vehicle must be Motorized.

After Whim accepts that relationship, ordinary inheritance makes descendants of Motorized descendants of Vehicle too. The root's permission list does not need another check for each deeper descendant.

Reflection keeps the family visible#

The permitted list remains available after linking:

use Whim\Reflection;

$option = Reflection\reflect_interface('Whim\\Option\\Option');
assert!($option is !null);

$permitted = $option->getPermittedSubtypeNames();
assert!($permitted is !null);

foreach ($permitted as $name) {
  write_line!($name);
}

getPermittedSubtypeNames() returns null for an unsealed class or interface. For a sealed declaration, it returns the fully qualified names from the for clause.

These are permitted names, not a list of every loaded descendant.

A documentation generator can show the family. Other tools can inspect the restriction without parsing source code or trying to infer it from whichever classes happen to be loaded.

Hack got here first#

Just like vec, dict, and tuples, Hack influenced this design. It has a __Sealed attribute:

<<__Sealed(Some::class, None::class)>>
interface Option {}

Its restriction also applies at one level. The permitted children may remain open, become final, or restrict their own children.

That was one of the inspirations for my PHP RFC, and the same idea later reached Whim.

Whim puts the permission list in the declaration grammar:

interface Option for Some, None {}

I prefer that spelling for an inheritance rule. The parser, compiler, and tools can read it as part of the declaration without attaching a special meaning to an attribute name.

PHP already does this for itself#

PHP already has interfaces that user code cannot freely implement.

Throwable is one. You can extend Exception or Error, but you cannot write an unrelated class that implements Throwable, even if you provide all its methods. PHP checks the class hierarchy and rejects implementations outside those families.

DateTimeInterface is another. Its built-in implementations are DateTime and DateTimeImmutable. You can extend either class, but an independent date class written in PHP cannot implement that interface. The date extension enforces this restriction.

PHP needs a shared type for each family, and it needs to control which implementations can belong to it.

There is no sealed declaration behind either interface, though. The restrictions live in C.

Zend Engine gives native interfaces a callback named interface_gets_implemented. When linking a class to an interface, the engine calls that callback if one exists. It lets the interface's implementation reject the class. Here is the engine code that calls it.

Throwable registers its callback like this:

zend_ce_throwable->interface_gets_implemented = zend_implement_throwable;

The assignment lives in the exception registration code. The callback walks up the parent chain and checks whether the root is Exception or Error. Otherwise, it raises a fatal error.

The date extension registers its own check:

date_ce_interface->interface_gets_implemented = implement_date_interface_handler;

The assignment lives in the date class registration code. That callback rejects a user class unless it derives from DateTime or DateTimeImmutable.

These checks differ from Whim's exact direct-child lists. PHP allows interfaces to extend these interfaces; the callback checks implementing classes. The date callback also treats native classes differently from user classes. Each restriction has its own rules in C, as the engine check and the date callback show.

But the need for controlled implementations already exists in PHP. The engine and its extensions can enforce it. An ordinary PHP library cannot declare it.

That was part of the motivation for the RFC. Its introduction mentions both interfaces.

You should not need to write a C extension to decide who may implement your interface.

I already tried to put this in PHP#

In 2021, Joe Watkins and I proposed sealed classes for PHP. Joe helped me through the RFC process, co-authored the proposal, and built the proof of concept.

The RFC covered classes, interfaces, and traits. Its main syntax looked like this:

sealed interface Option permits Some, None
{
}

The list controlled direct inheritance, implementation, or trait use. Permitted children could remain open or restrict their own descendants. The listed types did not need to exist when the parent loaded, and reflection would expose the restriction.

The vote took place in March 2022 and ended with 16 in favor and 11 against. That was a majority, but it fell short of the required two-thirds. The RFC was declined.

PHP rejected a general sealed-types feature.

The syntax nobody voted for#

The RFC had a second vote for syntax. There were three choices:

sealed class Foo permits Bar, Baz {}
class Foo permits Bar, Baz {}
class Foo for Bar, Baz {}

The result was:

Syntax Votes
sealed + permits 12
permits only 15
for 0

Zero people voted for for.

Naturally, that is the syntax Whim uses.

Looking back, I think the proposal's preferred sealed ... permits ... spelling was the wrong choice. It introduced two keywords to express one relationship.

for was already reserved by PHP. Reusing it would not take another identifier away from user code. It is shorter, and after using it in Whim, it reads naturally to me:

interface Option for Some, None {}

This interface is for these types.

I wanted Whim to need fewer keywords, and I ended up preferring the choice that received no votes. Trying the feature in a language I could change helped me see what I actually wanted from it.

That is one reason Whim exists: to explore language ideas by putting them into use, with the hope that some of what we learn can help PHP too.

The old RFC and Whim differ#

The RFC allowed a broader permission rule. If a sealed declaration permitted a type, another declaration could inherit directly from it when it was already a subtype of that permitted type. The proposal describes this with an interface example.

Whim uses exact direct names instead:

interface Vehicle for Motorized {}
interface Motorized extends Vehicle for Car {}
final class Car implements Motorized {}

Motorized is the permitted direct child of Vehicle. Car enters through Motorized.

If Car also explicitly listed Vehicle as a directly implemented interface, it would need permission from Vehicle too. Being a subtype of Motorized does not grant that extra permission.

I find this rule easier to read and explain. The declaration tells you the exact names that may directly inherit from it.

A second PHP RFC should revisit the details rather than copy the first one unchanged. That includes the permission rule, reflection, autoloading, aliases, anonymous classes, and how libraries intend people to extend their APIs.

The old proof of concept showed that we could build the feature. Using it in Whim has given me more to say about how it should behave.

When sealing is the wrong choice#

A framework author might seal every interface just because all the current implementations are known.

Then someone tries to add a database driver, logger, cache, or test double and finds that the framework forbids it.

That would be shitty API design.

Use an open interface when third-party implementations are part of its purpose. Use a sealed family when unknown implementations would break the assumptions that make the family useful.

Good candidates include result and option types, syntax-tree nodes, runtime reflection objects, and internal state variants. A shared service contract meant for application code to implement usually belongs in the open group.

Before sealing an interface, I want to be able to explain why a new implementation would be invalid. Knowing only that I have not needed one yet is not enough.

Could PHP make this a language feature?#

Yes. PHP already has the internal checks we just looked at, and the old RFC had a prototype for a general feature.

A new implementation would need declaration syntax, metadata for the permitted names, checks during linking, reflection APIs, and tests for the ways PHP loads and composes types. It would also need agreed rules for the cases where the original proposal and Whim differ.

I would not call that a one-afternoon patch. The old code needs review against current PHP, and the language rules matter more than the implementation.

But the feature has concrete uses, including uses inside PHP itself. A library author should be able to make the same sort of choice without writing C.

I would use for in another proposal. I would also spend more time explaining when sealing helps and when it gets in the way.

Would it pass this time? I have no idea. A clearer proposal might do better, or internals might reject it again.

What Whim taught me#

Before implementing sealing in Whim, I already liked the idea enough to write a PHP RFC. Using it still changed my mind about parts of the design.

I prefer the syntax nobody voted for. I prefer an exact direct-child list. I want reflection to keep that list available to tools. And I do not need exhaustive matching to justify the feature, even though it could benefit from the same information later.

This declaration makes the library's intent clear:

interface Result<out T, out E> for Ok, Err {}

Result defines the contract. Ok and Err are its permitted direct implementations. An application cannot add a third one.

Some interfaces should accept implementations from anyone. Others need a way to say who may implement them.

PHP already makes that distinction for some of its own types. I would like PHP library authors to have it too.

Comments

Have a question or something to add? Sign in with GitHub to join the discussion.

View discussions on GitHub ↗