Whats new in Symfony and PHP?



PHP Meetup, Montreal, Canada - February 24th, 2026

© David Buchmann







David Buchmann - david@liip.ch

PHP Engineer, Liip SA, Switzerland

8.4: Property Hooks

class Foo
{
    private int $property;

    public function getProperty(): int
    {
        return $this->property;
    }
            
class Foo
{
    public private(set) int $property;

Custom getter

class Foo
{
    public private(set) int $property {
        get => $this->override ??
            $this->property;
    }
Still accessed like a public property
echo $foo->property;

Combined Constructor Argument Promotion and Property Hooks

class Foo
{
    public function __construct(
        public private(set) int $property {
            get => $this->override ??
                $this->property;
        },
    ) {
    }
}
            
If your methods are non trivial, this will become hard to read!

8.4: No need for parenthesis anymore

new MyClass()->method();
// until PHP 8.3 you need to write (new MyClass())->method();
            

Before URI extension

$url = 'https://api.example.com:8080/users?id=123&sort=name#profile';
$parts = parse_url($url);

echo $parts['scheme'];    // https
echo $parts['host'];      // api.example.com
echo $parts['port'];      // 8080
echo $parts['path'];      // /users
echo $parts['query'];     // id=123&sort=name

8.5: URI extension

use Uri\Rfc3986\Uri;

$uri = new Uri('https://api.example.com:8080/users?id=123&sort=name#profile');

echo $uri->getScheme();    // https
echo $uri->getHost();      // api.example.com
echo $uri->getPort();      // 8080
echo $uri->getPath();      // /users
echo $uri->getQuery();     // id=123&sort=name
            

8.5: URI extension - modify

$newUri = $uri
    ->withScheme('http')
    ->withPath('/new-path')
    ->withQuery('updated=true');

echo $newUri->toString(); // http://example.com:8080/new-path?updated=true
            

8.5: URI extension - whatwg vs RFC 3986

WHATWG RFC 3986
  • Aimed at browser
  • Forgiving (whitespace becomes %20, umlaut in domain punycode)
  • Automatically normalizing
  • Strict standard
  • Precise, no "magic"
  • Supports URN too
WHATWG: when parsing HTML from websites (website might rely on the forgiving browser behaviour).
RFC: Everything else (strict and no magic).

8.5: array_first/array_last

- $first = reset($items);
- $first = $items[array_key_first($items)];
+ $first = array_first($items);

- $last  = end($items);
- $last  = $items[array_key_last($items)];
+ $last  = array_last($items);
            

8.5: exception / error handler

get_exception_handler();
get_error_handler();
Prior to this, we only could examine the return value of set_exception_handler / set_error_handler, at which point the handler was already replaced.

8.5: fatal error stack traces

Finally some good indication of the stack trace leading to a fatal error.

8.5: Pipe operator |>

$temp = "PHP Rocks";
$temp = htmlentities($temp);
$temp = str_split($temp);
$temp = array_map(strtoupper(...), $temp);
$result = array_filter($temp, fn($v) => $v != 'O');
print_r($result);

// or if you prefer less readable
print_r(array_filter(
    array_map(
        strtoupper(...),
        str_split(htmlentities('PHP Rocks'))
    ),
    fn($v) => $v != 'O'
))
            

8.5: Pipe operator |>

$result = "PHP Rocks"
    |> htmlentities(...)
    |> str_split(...)
    |> (fn($x) => array_map(strtoupper(...), $x))
    |> (fn($x) => array_filter(
            $x,
            fn($v) => $v != 'O'
       ))
;
print_r($result);

8.5: Clone with

return clone ($this, [
    'parent' => $parent,
]);
// respects visiblity, but ignores readonly
            



Autowire more than ever

use App\Repository\UnicornRepository;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Security\Core\Authorization\Attribute\IsGranted;

final class UnicornController extends AbstractController
{
    #[IsGranted('ROLE_UNICORN')]
    #[Route('/unicorns/{id}/glitter', methods: [Request::METHOD_POST])]
    public function addGlitter(
        string $id,
        Request $request,
        UnicornRepository $unicorns,
    ): JsonResponse {
        $user = $this->getUser();
        if (!$user) {
            die('This can not happen. We check IsGranted');
        }

        // Query parameter
        $force = filter_var(
            $request->query->get('force', '0'),
            FILTER_VALIDATE_BOOL
        );

        // Body
        $data = json_decode($request->getContent(), true);
        if (!is_array($data)) {
            return new JsonResponse(
                ['error' => 'Invalid JSON. Glitter must be well-formed.'],
                400
            );
        }
        
// Validate the body
if (!array_key_exists('color', $data) || !is_string($data['color'])) {
    return new JsonResponse(
        ['error' => 'Glitter color is required. Sad unicorn otherwise.'],
        400
    );
}
$color = $data['color'];
if (!preg_match('/^#[0-9A-Fa-f]{6}$/', $color)) {
    return new JsonResponse(
        ['error' => 'Color must be a valid RGB hex value (e.g. #FF00FF).'],
        400
    );
}

// Look up our unicorm
$unicorn = $unicorns->find($id);
if (null === $unicorn) {
    return new JsonResponse(
        ['error' => 'Unicorn not found.'],
        404
    );
}

// Business logic (finally!)
$unicorn->addGlitter(
    $color,
    $force,
    $user,
);

return new JsonResponse([
    'ok' => true,
    'msg' => sprintf(
        'Glitter deployed. %s is now 12%% more majestic.',
        $unicorn->getName()
    ),
]);
            

Autowire more than ever

final class UnicornController extends AbstractController
{
    #[IsGranted('ROLE_UNICORN')]
    #[Route('/unicorns/{id}/glitter', methods: [Request::METHOD_POST])]
    public function addGlitter(
        #[MapEntity] Unicorn $unicorn,
        #[MapQueryParameter] bool $force = false,
        #[MapRequestPayload] GlitterRequest $payload,
        UserInterface $user,
    ): JsonResponse {
        $unicorn->addGlitter(
            $payload->color,
            $force,
            $user->getUserIdentifier()
        );
        return new JsonResponse([
            'ok' => true,
            'msg' => sprintf(
                'Glitter deployed. %s is now 12%% more majestic.',
                $unicorn->getName()
            ),
        ]);}
}

Value Object

use Symfony\Component\Validator\Constraints as Assert;

final readonly class GlitterRequest
{
  public function __construct(
    #[Assert\NotBlank(message: 'Glitter color is required.')]
    #[Assert\Regex(
      pattern: '/^#[0-9A-Fa-f]{6}$/',
      message: 'Color must be a valid RGB hex color (e.g. #FF00FF).'
    )]
    public string $color,
  ) {}
}
            

Autowire in Controller methods

Value Resolvers

Symfony 7.3: Commands got improved too

use App\Repository\UnicornRepository;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;

final class UnicornGlitterCommand extends Command
{
    protected static $defaultName = 'unicorn:glitter';

    public function __construct(
        private UnicornRepository $unicorns,
    ) {
        parent::__construct();
    }

    protected function configure(): void
    {
        $this
            ->setDescription('Applies glitter to a unicorn')
            ->addArgument('id', InputArgument::REQUIRED, 'The unicorn id')
            ->addOption('color', 'c', InputOption::VALUE_REQUIRED,
                'RGB hex glitter color (e.g. #FF00FF)',
                '#FF00FF'
            )
            ->addOption('force', null, InputOption::VALUE_NONE,
                'Force glitter application, even if unicorn resists'
            )
            ->addOption('by', null, InputOption::VALUE_REQUIRED,
                'Who is applying the glitter (no current user on CLI)',
                'console-wizard'
           );
    }
            
// continued

    protected function execute(InputInterface $input, OutputInterface $output): int
    {
        $id = (string) $input->getArgument('id');
        $color = (string) $input->getOption('color');
        $force = (bool) $input->getOption('force');
        $by = (string) $input->getOption('by');

        if (!preg_match('/^#[0-9A-Fa-f]{6}$/', $color)) {
            $output->writeln('<error>Color must be a valid RGB hex value.</error>');
            return Command::INVALID;
        }

        $unicorn = $this->unicorns->find($id);
        if (null === $unicorn) {
            $output->writeln('<error>Unicorn not found.</error>');
            return Command::FAILURE;
        }

        $unicorn->addGlitter($color, $force, $by);

        $output->writeln(sprintf(
            '<info>Glitter deployed. %s is now 12%% more majestic.</info>',
            $unicorn->getName()
        ));

        return Command::SUCCESS;
    }
}
       

With argument and option attributes

use App\Repository\UnicornRepository;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Attribute\Argument;
use Symfony\Component\Console\Attribute\Option;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Style\SymfonyStyle;
use Symfony\Component\Validator\Constraints as Assert;
use Symfony\Component\Validator\Validator\ValidatorInterface;

#[AsCommand(
    name: 'unicorn:glitter',
    description: 'Applies glitter to a unicorn'
)]
final class UnicornGlitterCommand
{
    public function __construct(
        private readonly UnicornRepository $unicorns,
        private readonly ValidatorInterface $validator,
    ) {}

    public function __invoke(
        #[Argument(description: 'The unicorn id')]
        string $id,

        #[Option(name: 'color', shortcut: 'c',
            description: 'RGB hex glitter color',
        )]
        string $color = '#FF00FF',

        #[Option(name: 'force', description: 'Force glitter application')]
        bool $force = false,

        #[Option(name: 'by', description: 'Who is applying the glitter')]
        string $by = 'console-wizard',

        SymfonyStyle $io,

            
// command method contents

    ): int {
        // Validate input using Symfony Validator (same value object as controller)
        $input = new GlitterRequest($color);
        $violations = $this->validator->validate($input);

        if (count($violations) > 0) {
            foreach ($violations as $violation) {
                $io->error($violation->getMessage());
            }

            return Command::INVALID;
        }

        // Entity lookup (no MapEntity in console yet)
        $unicorn = $this->unicorns->find($id);
        if (null === $unicorn) {
            $io->error('Unicorn not found.');
            return Command::FAILURE;
        }

        // Same business logic as controller
        $unicorn->addGlitter($color, $force, $by);

        $io->success(sprintf(
            '✨ Glitter deployed. %s is now 12%% more majestic.',
            $unicorn->getName()
        ));

        return Command::SUCCESS;
    }
}
       

Command argument and option attributes

Symfony 7.4: Form Flows

Symfony 8.0: No more XML Configuration

Thank you!






https://phpc.social/@dbu

David Buchmann, Liip SA