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
- #[MapQueryParameter] (backed enum, array, bool, float, int, string, uid) - optionally with filter
- #[MapQueryString] with a dto class that matches the query parameters - supports validation
- #[MapRequestPayload] for body
- #[MapEntity] automatically load doctrine entities - configure if not default id or other method
- UserInterface
(or your custom user class and #[CurrentUser])
Value Resolvers
- Magic for controller done with ValueResolver
- You can add your own to expand the functionality
- Pass parameters e.g. to MapEntity to specify the parameter name and field name, or repository method to use
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
- Name from parameter name (can be overwritten in parameter to attribute)
- Looks at parameter type declaration
- Optional if default value
- Description and help as parameters to attribute
- Discussion about making the controller value resolvers work generically with commands:
- Would bring MapEntity and other goodies
- Complicated due to implementation details
Symfony 7.4: Form Flows
- Greatly improved support for multi step forms
- Each step is a regular form for the data of that step
- Create a flow type that combines the forms
- Can use one DTO for all steps. Validation can use step name as validation group
- Flow type supports skip callable to decide if a step is skipped
Symfony 8.0: No more XML Configuration
- Symfony removes XML service configuration in 8.0
- Recommended PHP configuration, YAML still supported
- https://github.com/GromNaN/symfony-config-xml-to-php
Thank you!
https://phpc.social/@dbu
David Buchmann, Liip SA