dave-liddament/php-language-extensions

Attributes for extending the PHP language, using static analysis to enforce new language constructs

0.9.0 2024-10-18 08:49 UTC

README

PHP versions: 8.0 to 8.4 Latest Stable Version License Total Downloads

Continuous Integration Psalm level 1 PHPStan level 8

This library provides attributes that are used by static analysers to enforce new language features. The intention, at least initially, is that these extra language features are enforced by static analysis tools (such as Psalm, PHPStan and, ideally, PhpStorm) and NOT at runtime.

Language feature added:

Contents

Installation

To make the attributes available for your codebase use:

composer require dave-liddament/php-language-extensions

NOTE: This only installs the attributes. A static analysis tool is used to enforce these language extensions. Use one of these:

PHPStan

If you're using PHPStan then use this extension to enforce the rules.

composer require --dev dave-liddament/phpstan-php-language-extensions

Psalm

Coming soon.

New language features

Friend

A method or class can supply via a #[Friend] attribute a list of classes. Only these classes can call the method. This is loosely based on the C++ friend feature.

In the example below the Person::__construct method can only be called from PersonBuilder:

class Person
{
    #[Friend(PersonBuilder::class)]
    public function __construct()
    {
        // Some implementation
    }
}


class PersonBuilder
{
    public function build(): Person
    {
        $person = new Person(): // OK as PersonBuilder is allowed to call Person's construct method.
        // set up Person
        return $person;
    }
}


// ERROR Call to Person::__construct is not from PersonBuilder
$person = new Person();

NOTES:

  • Multiple classes can be specified. E.g. #[Friend(Foo::class, Bar::class)]
  • A class can have a #[Friend] attribute, classes listed here are applied to every method.
    #[Friend(Foo::class)]
    class Entity
    {
      public function ping(): void // ping has friend Bar
      {
      }
    }
  • The #[Friend] attribute is additive. If a class and a method have the #[Friend] the method can be called from any of the classes listed. E.g.
    #[Friend(Foo::class)]
    class Entity
    {
      #[Friend(Bar::class)] 
      public function pong(): void // pong has friends Foo and Bar
      {
      }
    }
  • This is currently limited to method calls (including __construct).

MustUseResult

A #[MustUseResult] attribute can be used on methods. This enforces the result from the method call must be used.

E.g. if you have a class like this:

class Money {

  public function __construct(public readonly int $pence)
  {}
  
  #[MustUseResult]
  public function add(int $pence): self
  {
     return new self($pence + $this->pence);
  }
}

You might misuse the add method in this way:

$cost = new Money(5);
$cost->add(6); // ERROR - The call to the add method has no effect. 

But this would be OK:

$cost = new Money(5);
$updatedCost = $cost->add(6); // OK - The return from add method is being used.

NamespaceVisibility

The #[NamespaceVisibility] attribute acts as extra visibility modifier like public, protected and private. By default, the #[NamespaceVisibility] attribute limits the visibility of a class or method to only being accessible from in the same namespace, or sub namespace.

Example applying #[NamespaceVisibility] to the Telephone::ring method:

namespace Foo {

  class Telephone 
  {
    #[NamespaceVisibility]
    public function ring(): void
    {
    }
  }

  class Ringer
  {
    public function ring(Telephone $telephone): Person
    {
      $telephone->ring(); // OK calling Telephone::ring() from same namespace
    }
  }
}

namespace Foo\SubNamespace {

  use Foo\Telephone;
  
  class SubNamespaceRinger
  {
    public function ring(Telephone $telephone): Person
    {
      $telephone->ring(); // OK calling Telephone::ring() from sub namespace
    }
  }
}


namespace Bar {

  use Foo\Telephone;
  
  class DifferentNamespaceRinger
  {
    public function ring(Telephone $telephone): Person
    {
      $telephone->ring(); // ERROR calling Telephone::ring() from different namespace
    }
  }
}

The #[NamespaceVisibility] attribute has 2 optional arguments:

excludeSubNamespaces option

This is a boolean value. Its default value is false. If set to true then calls to methods from sub namespaces are not allowed. E.g.

namespace Foo {

  class Telephone 
  {
    #[NamespaceVisibility(excludeSubNamespaces: true)]
    public function ring(): void
    {
    }
  }

}

namespace Foo\SubNamespace {

  use Foo\Telephone;
  
  class SubNamespaceRinger
  {
    public function ring(Telephone $telephone): Person
    {
      $telephone->ring(); // ERROR - Not allowed to call Telephone::ring() from a sub namespace
    }
  }
}

namespace option

This is a string or null value. Its default value is null. If it is set, then this is the namespace that you are allowed to call the method on.

In the example below you can only call Telephone::ring from the Bar namespace.

namespace Foo {

  class Telephone 
  {
    #[NamespaceVisibility(namespace: "Bar")]
    public function ring(): void
    {
    }
  }
  
  class Ringer 
  {
    public function ring(Telephone $telephone): void
    {
      $telephone->ring(); // ERROR - Can only all Telephone::ring() from namespace Bar
    }
  }
}

namespace Bar {

  use Foo\Telephone;
  
  class AnotherRinger
  {
    public function ring(Telephone $telephone): void
    {
      $telephone->ring(); // OK - Allowed to call Telephone::ring() from namespace Bar
    }
  }
}

NamespaceVisibility on classes

If a class was the #[NamespaceVisibility] Attribute, then all its public methods are treated as Namespace visibility.

E.g.

namespace Foo {

  #[NamespaceVisibility()]
  class Telephone 
  {
    public function ring(): void // This method has NamespaceVisibility
    { }
  }
}

If both the class and one of the class's methods has a #[NamespaceVisibility] attribute, then the method's attribute takes precedence.

namespace Foo {

  #[NamespaceVisibility(namespace: 'Bar')]
  class Telephone 
  {
    #[NamespaceVisibility(namespace: 'Baz')]
    public function ring(): void // This method can only be called from the namespace Baz
    { }
  }
}

NOTES:

  • If adding the #[NamespaceVisibility] to a method, this method MUST have public visibility.
  • This is currently limited to method calls (including __construct).

InjectableVersion

The #[InjectableVersion] is used in conjunction with dependency injection. #[InjectableVersion] is applied to a class or interface. It denotes that it is this version and not any classes that implement/extend that should be used in the codebase.

E.g.

#[InjectableVersion]
class PersonRepository {...} // This is the version that should be type hinted in constructors.

class DoctrinePersonRepository extends PersonRepository {...}

class PersonCreator {
    public function __construct(
        private PersonRepository $personRepository, // OK - using the injectable version
    )
}
class PersonUpdater {
    public function __construct(
        private DoctrinePersonRepository $personRepository, // ERROR - not using the InjectableVersion
    )
}

This also works for collections:

#[InjectableVersion]
interface Validator {...} // This is the version that should be type hinted in constructors.

class NameValidator implements Validator {...}
class AddressValidator implements Validator {...}

class PersonValidator {
    /** @param Validator[] $validators */
    public function __construct(
        private array $validators, // OK - using the injectable version
    )
}

By default, only constructor arguments are checked. Most DI should be done via constructor injection.

In cases where dependencies are injected by methods that aren't constructors, the method must be marked with a #[CheckInjectableVersion]:

#[InjectableVersion]
interface Logger {...}

class FileLogger implements Logger {...}

class MyService 
{
    #[CheckInjectableVersion]
    public function setLogger(Logger $logger): void {} // OK - Injectable Version injected
    
    public function addLogger(FileLogger $logger): void {} // No issue raised because addLogger doesn't have the #[CheckInjectableVersion] attribute.
}

Override

The #[Override] attribute is used to denote that a method is overriding a method in a parent class. This is the functionality is similar to the @override annotation in Java.

This is temporary until PHP 8.3 is released. See the RFC that will be implemented in PHP 8.3.

NOTE:

  • If you are using PHP 8.3 then use the real #[Override] attribute.
  • This implementation doesn't consider traits.

RestrictTraitTo

This limits the use of a Trait to only be used by a specified class of a child of that class.

E.g. this trait is limited to classes that are or extend Controller

#[RestrictTraitTo(Controller::class)]
trait ControllerHelpers {}

This would be allowed:

class LoginController extends Controller {
    use ControllerHelpers;
}

But this would NOT be allowed:

class Repository {
    use ControllerHelpers;
}

Sealed

This is inspired by the rejected sealed classes RFC

The #[Sealed] attribute takes a list of classes or interfaces that can extend/implement the class/interface.

E.g.

#[Sealed([Success::class, Failure::class])]
abstract class Result {} // Result can only be extended by Success or Failure

// OK
class Success extends Result {}

// OK
class Failure extends Result {}

// ERROR AnotherClass is not allowed to extend Result
class AnotherClass extends Result {}

TestTag

The #[TestTag] attribute is an idea borrowed from hardware testing. Classes or methods marked with this attribute are only available to test code.

E.g.

class Person {

    #[TestTag]
    public function setId(int $id) 
    {
      $this->id = $id;
    }
}


function updatePersonId(Person $person): void 
{
    $person->setId(10);  // ERROR - not test code.
}


class PersonTest 
{
    public function setup(): void
    {
        $person = new Person();
        $person->setId(10); // OK - This is test code.
    }
}

NOTES:

  • Classes with the #[TestTag] will have an error when any interaction with the class is done.
  • Methods with the #[TestTag] MUST have public visibility.
  • For determining what is "test code" see the relevant plugin. E.g. the PHPStan extension can be setup to either:

Deprecated Attributes

Package (deprecated)

The #[Package] attribute acts like an extra visibility modifier like public, protected and private. It is inspired by Java's package visibility modifier. The #[Package] attribute limits the visibility of a class or method to only being accessible from code in the same namespace.

This has been replaced by the #[NamespaceVisibility] attribute. To upgrade replace:

#[Package] with #[NamespaceVisibility(excludeSubNamespaces=true)]

NOTES:

  • If adding the #[Package] to a method, this method MUST have public visibility.
  • If a class is marked with #[Package] then all its public methods are treated as having package visibility.
  • This is currently limited to method calls (including __construct).
  • Namespaces must match exactly. E.g. a package level method in Foo\Bar is only accessible from Foo\Bar. It is not accessible from Foo or Foo\Bar\Baz

Further examples

More detailed examples of how to use attributes is found in examples.

Contributing

See Contributing.