Skip to content

Latest commit

 

History

History
578 lines (400 loc) · 17.2 KB

6.5-new-php-language-features.md

File metadata and controls

578 lines (400 loc) · 17.2 KB

2023-05-16 - PHP 8.1 & Symfony 6.1 new features

Context

As of Shopware 6.5 the minimum version of PHP is 8.1 and the minimum version of Symfony is 6.1. We would like to promote the usage of the newly available features.

Many of the new features allow us to reduce boilerplate, make it easier to prevent common mistakes, improve refactoring support, increase legibility, perform faster and so on.

By using the latest features we allow the reader and writer of code to focus on the domain rather than the language.

PHP 8.0 & 8.1 new features

Promoted Properties

We have automatically refactored all existing code to use Promoted Properties using Rector.

Promoted properties allow us to reduce the boilerplate when defining classes, by removing the need to type the property name four times and the type twice.

Class properties, with their visibility and flags can now be specified entirely in the constructor.

From:

class Point {
    private int $x;
    private int $y;
    public function __construct(int $x, int $y)
    {
        $this->x = $x;
        $this->y = $y;
    }
}

To:

class Point {
    public function __construct(private int $x, private int $y)
    {
    }
}

Note: It is still possible to use normal property definitions/assignments with promoted properties. For example, if you need to manipulate some dependencies.

Advantages:

  • Less code, less duplication.
  • Better refactoring.

Backwards Compatibility / Migration Strategy

Migrating to promoted properties does not represent a breaking change.

New in initializers

It is now possible to specify an object as a default parameter value in a function/method. Previously it was only possible to specify scalar values.

From:

class PasswordHasher
{
    private Hasher $hasher;
    public function __construct(private Hasher $hasher = null)
    {
        $this->hasher = $hasher ?? new Bcrypt();
    }
}

To:

class PasswordHasher
{
    public function __construct(private Hasher $hasher = new Bcrypt())
    {}
}

Advantages:

  • Less code
  • More consistent

Backwards Compatibility / Migration Strategy

Migrating to inline object default parameters does not represent a breaking change.

Match

We have automatically refactored all existing code to use match instead of switch using Rector.

In most cases, switch statements can be replaced with match statements:

  • Match uses strict equality unlike switch which uses weak comparison and can lead to subtle bugs.
  • Each match arm does not fall through without a break statement, unlike switch.
  • Match expressions must be exhaustive, if there is no default arm specified, and no arm matches the given value, an UnhandledMatchError is thrown.
  • Match is an expression and thus returns a value, reducing unnecessary variables and reducing the risk of accessing undefined variables.

From:

switch ($statusCode) {
    case 200:
    case 300:
        $message = null;
        break;
    case 400:
        $message = 'not found';
        break;
    case 500:
        $message = 'server error';
        break;
    default:
        $message = 'unknown status code';
        break;
}

To:

$message = match ($statusCode) {
    200, 300 => null,
    400 => 'not found',
    500 => 'server error',
    default => 'unknown status code',
};

Note: Conditions can be combined in a much simpler fashion.

Backwards Compatibility / Migration Strategy

There are cases where migrating from a switch to a match could case a BC break. For example switch performs lose type checks and throws an exception for unhandled values.

When migrating code, be sure to check that values are the correct types and that all cases are handled.

New string functions

  • str_contains
  • str_starts_with
  • str_ends_with

Advantages:

  • Simpler and more concise.
  • Saner return types.
  • It is harder to get their usage wrong, for example checking for 0 vs false with strpos.
  • The functions are faster, being implemented in C.
  • The operations require less function calls, for example no usages of strlen are required.

Named arguments

Named arguments are useful when calling code with bad and/or large API's. For example, many of PHP's global functions.

In terms of calling bad PHP API's, the following advantages apply:

  • It is possible to skip defaults in between the arguments you want to change.
  • The code is better documented since the argument label is specified with the value, very useful for boolean flags.

From:

htmlspecialchars($string, ENT_COMPAT | ENT_HTML, 'UTF-8', false);

To:

htmlspecialchars($string, double_encode: false);

Note: The second argument is not changed, but in the first example we must provide the default value, in order to change the double encode flag.

Backwards Compatibility / Migration Strategy

We do not want to use named parameters when calling Shopware API's as parameter names are not a part of the Backwards compatability promise.

Named parameters should only be used when calling PHP API's.

Type improvements

It will now only be necessary to reach for @var & @param annotations when defining array shapes, generics and more specific types such as class-string, positive-int etc. Everything else should be natively typed.

When a type can really be any value, this can now be expressed as mixed.

When a type can be multiple, but not all, this can now be expressed as a union type, eg: int|string.

When a type must be an intersection of multiple types, this can now be expressed as an intersection type, eg: MyService&MockObject.

These improvements come with various advantages:

  • The types are enforced by PHP, so TypeError's will be thrown when attempting to pass non-valid types.
  • It allows us to move more type information from phpdoc into function signatures.
  • It prevents incorrect function information. phpdocs can often become stale when they are not updated with the function itself.

Enums

PHP finally has native support for enumerations, with various advantages over common userland packages and using const's.

Enums are useful where we have a predefined list of constant values. It's now not necessary to provide values as constants, and it's not necessary to create arrays of the constants to check validity.

From:

class Indexer
{
    public const PARTIAL = 'partial';
    public const FULL = 'full';

    public function product(int $id, string $method): void
    {
        if (!in_array($method, [self::PARTIAL, self::FULL], true)) {
            throw new \InvalidArgumentException();
        }
    
        match ($method) {
            self::PARTIAL => $this->partial($id),
            self::FULL => $this->full($id)
        };
    }
}

To:

enum IndexMethod
{
    case PARTIAL;
    case FULL;
}

class Indexer
{
    public function product(int $id, IndexMethod $method): void
    {
        match ($method) {
            IndexMethod::PARTIAL => $this->partial($id),
            IndexMethod::FULL => $this->full($id)
        };
    }
}

Advantages:

  • Works great with match - an UnhandledMatchError exception will be thrown if there is no match arm for a given enum case.
  • Can type hint on an enum.
  • No need to validate a case.
  • Can provide backed values and serialize/unserialize with MyEnum::from() && MyEnum::tryFrom().
  • Enums can provide methods and implement interfaces.
  • Better comparison features, e.g. Enums are singletons.

Backwards Compatibility / Migration Strategy

See the Use PHP 8.1 Enums ADR for the decision and migration strategy.

Readonly properties

Readonly properties are very useful when building DTOs. When you want to communicate a payload to a system or service, readonly properties allow us to create immutable data structures with a lot less code.

In conjunction with promoted properties, we can reduce the boilerplate of a class significantly. Consider a product reindex command:

From:

class ProductReindexCommand
{
    private int $productId;
    
    private bool $includeStock:
    
    public function __construct(int $productId, bool $includeStock)
    {
        $this->productId = $productId;
        $this->includeStock = $includeStock;
    }
    
    public function getProductId(): int
    {
        return $this->productId;
    }
    
    public function includeStock(): bool
    {
        return $this->includeStock;
    }
}

To:

class ProductReindexCommand
{
    public function __construct(public readonly int $productId, public readonly bool $includeStock)
    {
    }
}

In the first example, we use private properties to prohibit updates and public getters to allow access to the data. In the second we change the properties to public to allow access to the data without getters, but use readonly to prohibit updates. We also use promoted properties to make it even more succinct.

Advantages:

  • Reduced boilerplate.
  • Make the intent of code clearer.

Backwards Compatibility / Migration Strategy

All private properties, which are not written to after instantiation can successfully be migrated to readonly without BC breaks.

New code can use readonly on public and protected properties, but for existing code, that would be a BC break.

First-class callable syntax

This is a new method of referencing callables with strings and arrays. It allows for improved refactoring support, better static analysis and fixes some subtle bugs with scope.

Consider an operation to find the longest string in an array, you might use strlen within an array_map:

From:

$longest = max(array_map('strlen', $strings));

To:

$longest = max(array_map(strlen(...), $strings));

Instead of using an arbitrary string as a reference to a function, we can now use the (...) syntax to create a callable.

It can also be used with object methods, instance or static, e.g.:

$callable = $object->doCoolStuff(...);
$callable = \My\Object::doCoolStuff(...);

Advantages:

  • Refactoring support.
  • Better static analyses.
  • Fixes scope issues.

Attributes

We have automatically refactored all existing code to use attributes instead of annotations using Rector.

It is now possible to use native PHP attributes to store structured metadata, rather than using the error-prone PHP docblock.

For us Shopware developers, this will be most useful in conjunction with Symfony bundled attributes, which allow us to configure services and routes directly in controllers and services.

From:

namespace App\Controller;

use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\Routing\Annotation\Route;

class BlogController extends AbstractController
{
    /**
     * @Route("/blog", name="blog_list")
     */
    public function list(): Response
    {
        // ...
    }
}

To:

namespace App\Controller;

use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\Routing\Annotation\Route;

class BlogController extends AbstractController
{
    #[Route('/blog', name: 'blog_list')]
    public function list(): Response
    {
        // ...
    }
}

Advantages:

  • Add Metadata to classes, methods, properties, arguments and so on.
  • They can replace PHP doc blocks, each with custom parsers and rules to a unified standard supported by PHP.
  • Type safety & autocompletion.
  • The data can be introspected using PHP's Reflection API's.

Nullsafe operator

The nullsafe operator works the same as property or method accesses, except that if the object being dereferenced is null then null will be returned rather than an exception thrown. If the dereference is part of a chain, the rest of the chain is skipped.

Put another way; it allows chaining multiple property or method accesses on an object, without first checking if each returned value is null before proceeding.

Consider the following code:

class User
{
    public string $firstName;
    public string $lastName;
    public ?int $age = null;
    public ?Address $address = null;
}

class Address
{
    public int $number;
    public string $addressLine1;
    public ?string $addressLine2 = null;
}

Pre PHP 8.0, in order to access addressLine2 for an address of a user, it would be necessary to write the following code:

$user = new User(/**  */);
$address = $user->address;

if ($address !== null) {
   $addressLine2 = $address->addressLine2;
   
   if ($addressLine2 !== null) {
       //do something
   }
}

Instead, we can now write:

$user = new User(/**  */);
$addressLine2 = $user?->address?->addressLine2;

if ($addressLine2 !== null) {
    //do something
}

Advantages:

  • Much less code for simple operations where null is a valid value.
  • If the operator is part of a chain, anything to the right of the null will not be executed, the statements will be short-circuited.
  • Can be used on methods where null coalescing cannot $user->getCreatedAt()->format('d-m-Y') ?? null where getCreatedAt() could return null or a \DateTime instance.

Other

There are many more changes, including deprecations and backwards compatibility breaks. Please read the official announcement pages for both PHP 8.0 & PHP 8.1 for a deeper understanding:

Symfony 6.1 new features

Enums in route definitions

Symfony Blog

We can specify route parameters which will be validated against a given enums cases:

use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Routing\Requirement\EnumRequirement;

#[Route('/foo/{bar}', requirements: ['bar' => new EnumRequirement(SomeEnum::class)])]

Service autowiring attributes

Symfony Blog

We can now wire up dependencies without touching XML. It is possible to define the required services directly in the class:

use Symfony\Component\DependencyInjection\Attribute\Autowire;

class Mailer
{
    public function __construct(
        #[Autowire(service: 'email_adapter')]
        private Adapter $adapter,

        #[Autowire('%kernel.debug_mode%')]
        private bool $debugMode,
    ) {}
}

Further to that, we can decorate services with attributes: https://symfony.com/blog/new-in-symfony-6-1-service-decoration-attributes

Backwards Compatibility / Migration Strategy

See the Symfony Dependency Management ADR for the decision and migration strategy.

Improved console autocompletion

Symfony Blog

Autocompletion values can now be defined directly in the command input definition, as the 5th parameter, for both arguments and inputs:

public function configure(): void
{
    $this->addArgument(
        'features',
        InputArgument::REQUIRED | InputArgument::IS_ARRAY,
        'The features to enable',
        null,
        fn () => self::availableFeatures()
    );
}