DEV Community

Cover image for Mastering Symfony UX 3.0.0 with a Modern Real Estate Platform
Matt Mochalkin
Matt Mochalkin

Posted on

Mastering Symfony UX 3.0.0 with a Modern Real Estate Platform

The release of Symfony UX 3.0.0 is a monumental shift. By stripping away all the 2.x deprecations and bumping the minimum requirements to PHP 8.4 and Symfony 7.4, the Symfony core team has delivered the leanest, most powerful version of the UX ecosystem yet.

Gone are the days of relying on thin PHP wrappers for simple JavaScript libraries (farewell Swup, Typed, LazyImage and TogglePassword). Instead UX 3.0.0 doubles down on what matters: robust Twig components, seamless frontend-backend data binding and native web standards.

In this deep-dive tutorial, we are going to explore the raw power of Symfony UX 3.0.0 by building a Real Estate Property Creator. We will tackle dynamic UIs, complex relationship forms and image manipulation — all with zero custom JavaScript.

Setup and Verification

Before we write a single line of code, we need to ensure our environment meets the strict new standards. Symfony UX 3.0.0 demands a modern stack.

Prerequisites:

  • PHP 8.4+
  • Symfony 7.4+
  • Composer 2.x

Let’s install the exact packages required for our Real Estate application. We will be using Twig Components for our UI cards, Autocomplete for our property amenities and Cropper.js for our image gallery.

# Core components and the new required HTML extra package for CVA
composer require symfony/ux-twig-component:^3.0 symfony/ux-live-component:^3.0
composer require twig/html-extra:^3.12

# Form enhancements
composer require symfony/ux-autocomplete:^3.0
composer require symfony/ux-cropperjs:^3.0

# Ensure AssetMapper is ready (no Node.js required!)
composer require symfony/asset-mapper:^7.4
Enter fullscreen mode Exit fullscreen mode

Verification Step

To verify that all libraries are correctly installed and registered, run:

php bin/console debug:twig-component
Enter fullscreen mode Exit fullscreen mode

You should see a list of available components without any deprecation warnings. If you run php bin/console about, verify that your Symfony version reads 7.4.x and your PHP version is 8.4.x.

Crafting the UI with ux-twig-component 3.0

In Symfony UX 3.0.0, the ux-twig-component package received a major cleanup. The twig_component.defaults configuration is now mandatory and the old cva Twig function has been completely removed in favor of html_cva from twig/html-extra.

Let’s build a reusable PropertyCard component to display our listings.

The Configuration

First, we must define our mandatory defaults in config/packages/twig_component.yaml:

twig_component:
    anonymous_template_directory: 'components/'
    defaults:
        # We namespace our components under 'App\Twig\Components\'
        App\Twig\Components\: 'components/'
Enter fullscreen mode Exit fullscreen mode

The PHP Class (PHP 8.4 Style)

We use PHP 8.4’s constructor property promotion and strict typing. Notice the exclusive use of the #[AsTwigComponent] attribute.

namespace App\Twig\Components;

use Symfony\UX\TwigComponent\Attribute\AsTwigComponent;

#[AsTwigComponent('PropertyCard')]
final class PropertyCard
{
    public function __construct(
        public string $title = '',
        public int $price = 0,
        public string $status = 'active',
        public ?string $imageUrl = null,
        public ?int $id = null,
    ) {
    }
}
Enter fullscreen mode Exit fullscreen mode

The Twig Template with html_cva

Here is where the magic of the new html_cva function comes into play. It allows us to manage complex CSS classes (like Tailwind utility classes) based on the component’s state natively.

Create templates/components/PropertyCard.html.twig:

{# We use html_cva from twig/html-extra:^3.12 #}
{% set card_classes = html_cva({
    base: 'rounded-xl shadow-lg overflow-hidden transition-transform hover:scale-105',
    variants: {
        status: {
            active: 'bg-white border-2 border-green-500',
            sold: 'bg-gray-100 border-2 border-gray-400 opacity-75',
            pending: 'bg-yellow-50 border-2 border-yellow-500'
        }
    }
}) %}

<div {{ attributes.defaults({ class: card_classes.apply({ status: this.status }) }) }}>
    {% if this.imageUrl %}
        <img src="{{ this.imageUrl }}" alt="{{ this.title }}" loading="lazy" class="w-full h-48 object-cover">
    {% endif %}

    <div class="p-6">
        <h3 class="text-xl font-bold text-gray-900">{{ this.title }}</h3>
        <p class="mt-2 text-2xl font-extrabold text-blue-600">${{ this.price|number_format }}</p>

        {% if this.id %}
            <div class="mt-4 flex justify-between items-center border-t pt-4">
                <div class="space-x-4">
                    <a href="{{ path('app_property_edit', {id: this.id}) }}" class="text-blue-600 hover:text-blue-800 font-medium text-sm">✏️ Edit</a>
                    {% if this.imageUrl %}
                        <a href="{{ path('app_property_crop', {id: this.id}) }}" class="text-green-600 hover:text-green-800 font-medium text-sm">✂️ Crop</a>
                    {% endif %}
                </div>
                <form method="post" action="{{ path('app_property_delete', {id: this.id}) }}" onsubmit="return confirm('Are you sure you want to delete this property?');">
                    <input type="hidden" name="_token" value="{{ csrf_token('delete' ~ this.id) }}">
                    <button type="submit" class="text-red-600 hover:text-red-800 font-medium text-sm">🗑️ Delete</button>
                </form>
            </div>
        {% endif %}
    </div>
</div>
Enter fullscreen mode Exit fullscreen mode

The symfony/ux-lazy-image package was removed in 3.0.0, we simply use the native HTML loading=”lazy” attribute as shown above, adhering to modern web standards.

Smart Forms with ux-autocomplete 3.0

When a real estate agent creates a property listing, they need to tag it with amenities (Pool, Garage, Balcony, etc.). Loading thousands of tags into a standard box is a performance nightmare.

In UX 3.0.0, the ParentEntityAutocompleteType was officially removed. We must now use the streamlined BaseEntityAutocompleteType. Let’s build an AmenityAutocompleteField.

The Autocomplete Field Type

We extend the new BaseEntityAutocompleteType and configure it using attributes.

namespace App\Form;

use App\Entity\Amenity;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\UX\Autocomplete\Form\AsEntityAutocompleteField;
use Symfony\UX\Autocomplete\Form\BaseEntityAutocompleteType;

#[AsEntityAutocompleteField]
class AmenityAutocompleteField extends AbstractType
{
    public function configureOptions(OptionsResolver $resolver): void
    {
        $resolver->setDefaults([
            'class' => Amenity::class,
            'placeholder' => 'Search for amenities...',
            'choice_label' => 'name',
            'multiple' => true,
        ]);
    }

    public function getParent(): string
    {
        return BaseEntityAutocompleteType::class;
    }
}
Enter fullscreen mode Exit fullscreen mode

Integrating into the Property Form

Now, we seamlessly inject this into our main PropertyType form.

namespace App\Form;

use App\Entity\Property;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\FileType;
use Symfony\Component\Form\Extension\Core\Type\MoneyType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;

final class PropertyType extends AbstractType
{
    public function buildForm(FormBuilderInterface $builder, array $options): void
    {
        $builder
            ->add('title', TextType::class)
            ->add('price', MoneyType::class, [
                'currency' => 'USD',
                'divisor' => 1,
            ])
            ->add('photo', FileType::class, [
                'mapped' => false,
                'required' => false,
                'attr' => [
                    'accept' => 'image/*',
                    'class' => 'hidden',
                    'data-dropzone-target' => 'input',
                    'data-action' => 'change->dropzone#handleFiles'
                ]
            ])
            ->add('amenities', AmenityAutocompleteField::class);
    }

    public function configureOptions(OptionsResolver $resolver): void
    {
        $resolver->setDefaults([
            'data_class' => Property::class,
        ]);
    }
}
Enter fullscreen mode Exit fullscreen mode

Thanks to AssetMapper, the required JavaScript for TomSelect (which powers the autocomplete under the hood) is automatically resolved via importmap:require.

Picture Perfect with ux-cropperjs 3.0

Real estate is all about high-quality visuals. When an agent uploads a cover photo, they need to crop it perfectly.

Symfony UX 3.0.0 introduced a fantastic Quality of Life (QoL) improvement to symfony/ux-cropperjs. In the 2.x days you had to manually pass applyRotation: true to get the image oriented correctly based on EXIF data. In 3.0.0 the $applyRotation parameter is entirely gone, rotation is now always applied automatically.

The Form Controller

Let’s look at how clean the controller code is now when processing the form submission.

namespace App\Controller;

use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\UX\Cropperjs\Factory\CropperInterface;
use Symfony\UX\Cropperjs\Form\CropperType;
use App\Form\PropertyType;
use Doctrine\ORM\EntityManagerInterface;
use App\Entity\Property;
use App\Service\FileUploader;
use Symfony\UX\Cropperjs\Model\Crop;

final class PropertyController extends AbstractController
{
    public function __construct(
        private readonly CropperInterface $cropper,
        private readonly EntityManagerInterface $entityManager,
    ) {
    }

    ...

    #[Route('/property/{id}/crop', name: 'app_property_crop')]
    public function crop(Request $request, Property $property): Response
    {
        if (!$property->getImageUrl()) {
            return $this->redirectToRoute('app_property_index');
        }

        $projectDir = $this->getParameter('kernel.project_dir');
        $imagePath = $projectDir . '/public' . $property->getImageUrl();

        $crop = $this->cropper->createCrop($imagePath);
        $crop->setCroppedMaxSize(1920, 1080);

        $form = $this->createFormBuilder(['photo' => $crop])
            ->add('photo', CropperType::class, [
                'public_url' => $property->getImageUrl(),
                'cropper_options' => [
                    'aspectRatio' => 16 / 9,
                ],
            ])
            ->getForm();

        $form->handleRequest($request);

        if ($form->isSubmitted() && $form->isValid()) {
            /** @var Crop $cropData */
            $cropData = $form->get('photo')->getData();

            // Apply the crop and overwrite the original file
            $croppedImageContent = $cropData->getCroppedImage();
            file_put_contents($imagePath, $croppedImageContent);

            $this->addFlash('success', 'Photo cropped beautifully!');
            return $this->redirectToRoute('app_property_index');
        }

        return $this->render('property/crop.html.twig', [
            'property' => $property,
            'form' => $form->createView(),
        ]);
    }
Enter fullscreen mode Exit fullscreen mode

Moving Past Deprecations: Life without ux-typed

As part of the UX 3.0.0 release the core team removed symfony/ux-typed. This was a smart move — there’s no need for a PHP wrapper when you can write a tiny standard Stimulus controller.

Let’s add a typing effect to the top of our “Create Property” form to replace it, proving how easy it is to work with the underlying JavaScript directly using AssetMapper.

Install Typed.js natively

Instead of Composer we use Symfony’s AssetMapper to pull the pure JS library from NPM:

php bin/console importmap:require typed.js
Enter fullscreen mode Exit fullscreen mode

Create a Custom Stimulus Controller

Create assets/controllers/typing_controller.js:

import { Controller } from '@hotwired/stimulus';
import Typed from 'typed.js';

export default class extends Controller {
    static values = {
        strings: Array,
        speed: { type: Number, default: 50 }
    }

    connect() {
        this.typed = new Typed(this.element, {
            strings: this.stringsValue,
            typeSpeed: this.speedValue,
            loop: true,
        });
    }

    disconnect() {
        if (this.typed) {
            this.typed.destroy();
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Attach it to your Twig Template

Now, just bind the controller to your HTML header:

{% extends 'base.html.twig' %}

{% block title %}Create New Listing{% endblock %}

{% block body %}
    <div class="max-w-3xl mx-auto bg-white p-8 rounded-xl shadow-lg">
        <h1 class="text-4xl font-bold mb-8">
            List your next 
            <span 
                data-controller="typing" 
                data-typing-strings-value='["Mansion", "Cozy Condo", "Downtown Loft", "Beachfront Villa"]'
                data-typing-speed-value="75"
                class="text-blue-600"
            ></span>
        </h1>

        {{ include('property/_form.html.twig', {'button_label': 'Publish Listing'}) }}
    </div>
{% endblock %}
Enter fullscreen mode Exit fullscreen mode

Just like that, you have identical functionality to the old ux-typed package, but with cleaner code, no unnecessary PHP wrappers and total control over the JavaScript library.

Conclusion

Building our Real Estate Property Creator has proven one thing unequivocally: Symfony UX 3.0.0 is not just an upgrade - it’s a refinement of the entire frontend experience in the Symfony ecosystem.

By aggressively trimming the fat — dropping obsolete 2.x deprecations and retiring thin wrapper packages like Swup, Typed and LazyImage — the core team has delivered a framework that champions standard web practices and raw performance. Moving the baseline to PHP 8.4 and Symfony 7.4 forces us to write cleaner, strictly typed and attribute-driven code.

As we saw in our project:

  1. Twig Components are now safer and more robust with mandatory defaults and the powerful html_cva function natively handling complex UI state classes.
  2. Forms are significantly leaner. Upgrading to BaseEntityAutocompleteType and relying on the ux-cropperjs auto-rotation means less boilerplate and more focus on business logic.
  3. JavaScript Fatigue is officially dead. By utilizing AssetMapper and standard Stimulus controllers, replacing removed packages like ux-typed took less than 20 lines of vanilla JavaScript, fully integrated into our Twig templates.

If you have an existing application running smoothly on Symfony UX 2.x without deprecation notices, the jump to 3.0.0 will be a breeze. But beyond just surviving the upgrade, UX 3.0.0 invites you to rethink how you build user interfaces. It’s an invitation to stop wrestling with heavy JavaScript frameworks and start leveraging the unparalleled developer experience of modern PHP.

Before you dive into your codebase, be sure to read the official UPGRADE-3.0.md file in the Symfony UX repository for the granular code diffs. Then, fire up composer update, embrace the attributes and go build something beautiful.

Source Code: You can find the full implementation and follow the project’s progress on GitHub: [https://github.com/mattleads/RealEstateUX]

Let’s Connect!

If you found this helpful or have questions about the implementation, I’d love to hear from you. Let’s stay in touch and keep the conversation going across these platforms:

LinkedIn: [https://www.linkedin.com/in/matthew-mochalkin/]
X (Twitter): [https://x.com/MattLeads]
Telegram: [https://t.me/MattLeads]
GitHub: [https://github.com/mattleads]

Top comments (0)