# Symfony 8 in 2026: New Features, PHP 8.4 Lazy Objects and Interview Questions > Symfony 8 introduces native multi-step forms, invokable commands, PHP 8.4 lazy objects, and new JSON components. A deep dive into the features that matter for production and technical interviews. - Published: 2026-04-19 - Updated: 2026-04-19 - Author: SharpSkill - Tags: symfony, php, symfony-8, php-8.4, lazy-objects, web-framework - Reading time: 9 min --- Symfony 8, released in November 2025, removes two years of deprecations and ships the same feature set as Symfony 7.4 — but with a hard floor of PHP 8.4. That requirement unlocks native lazy objects, property hooks, and the new HTML5 parser, three language-level additions the framework now relies on internally. This article breaks down the features that change daily development, impact interview preparation, and deserve attention in production upgrades. > **Symfony 8 at a Glance** > > Symfony 8.0 requires PHP 8.4+, ships native multi-step forms (`AbstractFlowType`), invokable console commands with `#[Argument]` and `#[Option]` attributes, three new JSON/ObjectMapper components, and replaces proxy code generation with PHP 8.4 native lazy objects across DependencyInjection and Doctrine. ## Native Lazy Objects Replace Proxy Code Generation PHP 8.4 introduces lazy objects at the engine level through `ReflectionClass::newLazyGhost()` and `ReflectionClass::newLazyProxy()`. Symfony 8 leverages this directly: the DependencyInjection component no longer generates proxy classes for lazy services. Instead, it creates ghost objects that initialize on first property access. The practical consequence is significant. `final` and `readonly` classes — previously incompatible with Symfony's proxy-based lazy loading — now work without workarounds. Doctrine benefits equally: entity proxies no longer require code generation, removing a maintenance burden that persisted for over a decade. ```php // src/Service/HeavyReportGenerator.php namespace App\Service; use Symfony\Component\DependencyInjection\Attribute\Lazy; #[Lazy] final readonly class HeavyReportGenerator { public function __construct( private DatabaseConnection $db, private PdfEngine $pdf, private CacheInterface $cache, ) { // Constructor runs only when a method is actually called } public function generate(int $reportId): string { $data = $this->db->fetchReport($reportId); return $this->pdf->render($data); } } ``` The `#[Lazy]` attribute marks the service for ghost-object instantiation. The container injects a shell that looks identical to `HeavyReportGenerator`. The constructor — and the three injected dependencies — only execute when `generate()` is called. For services used conditionally (report generation triggered by a specific admin action, for example), this eliminates unnecessary database connections and memory allocation on every request. The `#[Autowire(lazy: true)]` variant allows lazy injection at the call site without modifying the service class itself: ```php // src/Controller/DashboardController.php namespace App\Controller; use Symfony\Component\DependencyInjection\Attribute\Autowire; class DashboardController { public function __construct( #[Autowire(lazy: true)] private HeavyReportGenerator $reportGenerator, ) {} } ``` In interview settings, expect questions about the difference between ghost objects and virtual proxies. Ghost objects initialize the original instance in place. Virtual proxies delegate to a separate fully-initialized instance. Symfony defaults to ghosts unless a factory is involved, in which case it switches to proxies automatically. ## Multi-Step Forms with AbstractFlowType Before Symfony 8, multi-step forms required third-party bundles like CraueFormFlowBundle. The framework now provides `AbstractFlowType` — a native form flow engine with per-step validation, conditional branching, and built-in navigation. ```php // src/Form/UserSignUpType.php namespace App\Form; use Symfony\Component\Form\Flow\AbstractFlowType; use Symfony\Component\Form\Flow\FormFlowBuilderInterface; use Symfony\Component\OptionsResolver\OptionsResolver; class UserSignUpType extends AbstractFlowType { public function buildFormFlow(FormFlowBuilderInterface $builder, array $options): void { $builder->addStep('personal', UserPersonalType::class); $builder->addStep('professional', UserProfessionalType::class); $builder->addStep('account', UserAccountType::class); $builder->add('navigator', NavigatorFlowType::class); } public function configureOptions(OptionsResolver $resolver): void { $resolver->setDefaults([ 'data_class' => UserSignUp::class, 'step_property_path' => 'currentStep', ]); } } ``` Each step name doubles as a validation group. The `UserSignUp` data class uses `#[Valid]` with group constraints to validate only the active step: ```php // src/DTO/UserSignUp.php namespace App\DTO; use Symfony\Component\Validator\Constraints as Assert; class UserSignUp { public function __construct( #[Assert\Valid(groups: ['personal'])] public Personal $personal = new Personal(), #[Assert\Valid(groups: ['professional'])] public Professional $professional = new Professional(), #[Assert\Valid(groups: ['account'])] public Account $account = new Account(), public string $currentStep = 'personal', ) {} } ``` The controller mirrors standard Symfony form handling. `isFinished()` returns `true` only after the final step passes validation: ```php // src/Controller/SignUpController.php #[Route('/signup')] public function __invoke(Request $request): Response { $flow = $this->createForm(UserSignUpType::class, new UserSignUp()) ->handleRequest($request); if ($flow->isSubmitted() && $flow->isValid() && $flow->isFinished()) { $this->userService->register($flow->getData()); return $this->redirectToRoute('app_signup_success'); } return $this->render('signup/flow.html.twig', [ 'form' => $flow->getStepForm(), ]); } ``` Navigation buttons (`NextFlowType`, `PreviousFlowType`, `FinishFlowType`, `ResetFlowType`) support conditional rendering via `include_if`, step skipping with `skip`, and direct navigation with `back_to`. This covers most real-world wizard patterns without custom JavaScript state management. ## Invokable Commands with Input Attributes Console commands no longer require extending the `Command` base class or overriding `configure()`. The `__invoke()` method receives arguments and options directly through PHP attributes. ```php // src/Command/CreateUserCommand.php namespace App\Command; 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; #[AsCommand( name: 'app:create-user', description: 'Create a new user account', )] class CreateUserCommand { public function __invoke( SymfonyStyle $io, #[Argument(description: 'The username for the new account')] string $username, #[Argument(description: 'The email address')] string $email, #[Option(shortcut: 'r', description: 'Role to assign')] string $role = 'ROLE_USER', #[Option(description: 'Activate immediately')] bool $activate = false, ): int { // User creation logic $io->success(sprintf('User "%s" created with role %s.', $username, $role)); return Command::SUCCESS; } } ``` Symfony 8 adds backed enum support for arguments and options. String input converts to the matching enum case automatically: ```php // src/Enum/CloudRegion.php enum CloudRegion: string { case UsEast = 'us-east'; case UsWest = 'us-west'; case EuWest = 'eu-west'; } // In the command public function __invoke( SymfonyStyle $io, #[Argument] CloudRegion $region, #[Option] ?ServerSize $size = null, ): int { $io->info(sprintf('Deploying to %s', $region->value)); return Command::SUCCESS; } ``` For commands with many parameters, `#[MapInput]` maps input to a DTO: ```php // src/Command/Input/DeployInput.php class DeployInput { #[Argument] public CloudRegion $region; #[Option] public string $branch = 'main'; #[Option(shortcut: 'f')] public bool $force = false; } // src/Command/DeployCommand.php #[AsCommand(name: 'app:deploy')] class DeployCommand { public function __invoke( SymfonyStyle $io, #[MapInput] DeployInput $input, ): int { // Access $input->region, $input->branch, $input->force return Command::SUCCESS; } } ``` This pattern eliminates the ceremony of `configure()` + `execute()` and makes commands testable by constructing the DTO directly. ## JSON Streamer, JSON Path, and ObjectMapper Components Three new standalone components ship with Symfony 8: **JsonStreamer** processes large JSON payloads without loading the entire document into memory. For APIs handling megabyte-scale responses — bulk data exports, analytics feeds, log ingestion — this avoids the memory ceiling of `json_decode()`. **JsonPath** implements a subset of RFC 9535 for navigating nested JSON structures. Instead of decoding an entire response and traversing arrays manually, a path expression extracts the target data directly. **ObjectMapper** converts between DTOs and arrays/JSON using PHP attributes, replacing hand-written hydration logic. Combined with the Serializer component, it covers the full lifecycle of API request/response mapping. > **Interview Relevance** > > Symfony interview questions in 2026 frequently cover lazy objects (ghost vs. proxy), the `AbstractFlowType` lifecycle, and the shift from `configure()` to invokable commands. Understanding these patterns signals familiarity with the current framework version, not just legacy knowledge. ## Security and DX Improvements Worth Noting CSRF protection no longer requires server-side sessions. The new stateless CSRF implementation works with HTTP caching, making it viable for API-first architectures and CDN-cached pages. The Messenger component supports **message signing** — cryptographic signatures on payloads prevent tampering between producer and consumer. For systems where message integrity matters (payment processing, audit trails), this replaces custom signing middleware. New validation constraints cover charsets, MAC addresses, ISO week numbers, word counts, YAML syntax, slugs, Twig templates, and video files. The `#[Slug]` constraint alone eliminates a common custom validator. Security voters now **explain their decisions** in the profiler. Debugging authorization logic — previously a process of guessing which voter denied access — becomes a direct lookup in the Symfony profiler toolbar. > **Upgrade Path** > > Symfony 8.0 drops all deprecations from the 7.x cycle, removing 13,202 lines of backward-compatibility code. The recommended approach: upgrade to Symfony 7.4 first, run `php bin/phpunit --display-deprecations`, fix every warning, then switch to 8.0. Skipping 7.4 risks runtime errors from removed APIs. ## PHP 8.4 Property Hooks in Symfony Context Property hooks (`get` and `set` defined directly on class properties) integrate naturally with Symfony's ecosystem. Doctrine entities can use hooks for computed properties. Form types can leverage hooks for data transformation. Validation constraints work on hooked properties without modification. ```php // src/Entity/Product.php class Product { public string $name { set(string $value) { $this->name = trim($value); } } public float $price { set(float $value) { if ($value < 0) { throw new \InvalidArgumentException('Price cannot be negative'); } $this->price = $value; } } public float $priceWithTax { get => $this->price * 1.20; } } ``` Property hooks reduce the need for getter/setter pairs and move invariant enforcement into the property definition. In Symfony forms, this means less transformer boilerplate. In Doctrine entities, computed values stay close to the data they depend on. ## Interview Questions to Prepare For Symfony 8 shifts what interviewers test. The following topics appear regularly in [Symfony interview preparations](/technologies/symfony/interview-questions/architecture-patterns): - **Lazy objects**: Explain the difference between ghost objects and virtual proxies. When does Symfony choose one over the other? Why does PHP 8.4 make `final readonly` services lazy-loadable? - **Multi-step forms**: How does `AbstractFlowType` handle per-step validation? What is the role of `step_property_path`? How do conditional steps work? - **Invokable commands**: What replaces `configure()` in invokable commands? How does `#[MapInput]` work? What are the rules for `#[Option]` defaults? - **New components**: When would JsonStreamer be preferred over `json_decode()`? What problem does ObjectMapper solve that the Serializer does not? - **Upgrade strategy**: Why is upgrading to 7.4 before 8.0 recommended? What does the removal of 13,202 lines of deprecation code imply for backward compatibility? Practice these with real code. The [Symfony console commands module](/technologies/symfony/interview-questions/console-commands) and the [Doctrine advanced module](/technologies/symfony/interview-questions/doctrine-advanced) cover the most frequently tested areas. ## Conclusion - Symfony 8 requires PHP 8.4 and fully adopts native lazy objects, eliminating proxy code generation for both the DI container and Doctrine ORM - `AbstractFlowType` replaces third-party bundles for multi-step forms, with per-step validation groups and built-in navigation buttons - Invokable commands with `#[Argument]`, `#[Option]`, and `#[MapInput]` remove boilerplate and support backed enums and DTOs - Three new components (JsonStreamer, JsonPath, ObjectMapper) address JSON processing and object mapping without external dependencies - Stateless CSRF, message signing, security voter debugging, and 20+ new validation constraints strengthen the security and DX surface - Property hooks from PHP 8.4 integrate transparently with Doctrine entities, Symfony forms, and validation constraints - The upgrade path runs through Symfony 7.4: fix all deprecations there before switching to 8.0 --- Source: SharpSkill (https://sharpskill.dev), tech interview preparation for your real stack. HTML version of this page: https://sharpskill.dev/en/blog/symfony/symfony-8-new-features-php-84-lazy-objects