Создаем новую колонку slug в требуемой сущности, в моем случае это Post

#[ORM\Column(length: 255, unique: true)]
private ?string $slug = null;

Генерируем миграцию командой (в моем случае приложение развернуто в Docker):

docker compose run --rm php-cli php bin/console make:migration

Новое поле slug не предполагает пустого значения, поэтому потребуется слегка изменить миграцию

<?php

declare(strict_types=1);

namespace DoctrineMigrations;

use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;

/**
 * Добавляем колонку SLUG.
 */
final class Version20260708100240 extends AbstractMigration
{
    public function getDescription(): string
    {
        return 'Добавляем колонку SLUG';
    }

    public function up(Schema $schema): void
    {
        // Создаем колонку с возможным значением NULL
        $this->addSql('ALTER TABLE post ADD slug VARCHAR(255)');
        // Заполняем для всех уже существующих статей
        $this->addSql('UPDATE post SET slug = concat(\'article-\', id)');
        // Убираем возможность значения NULL в колонке slug
        $this->addSql('ALTER TABLE post ALTER COLUMN slug SET NOT NULL');
        // Добавляем уникальный индекс на эту колонку
        $this->addSql('CREATE UNIQUE INDEX UNIQ_5A8A6C8D989D9B62 ON post (slug)');
    }

    public function down(Schema $schema): void
    {
        $this->addSql('DROP INDEX UNIQ_5A8A6C8D989D9B62');
        $this->addSql('ALTER TABLE post DROP slug');
    }
}

После того как мы выполним миграцию, устанавливаем пакет Symfony для работы с строками, командой

docker compose run --rm php-cli composer require symfony/string

Добавляем нового слушателя событий и привязываем его к событиям Doctrine для сущности Post

<?php

namespace App\EventListener;

use App\Entity\Post;
use Doctrine\Bundle\DoctrineBundle\Attribute\AsEntityListener;
use Doctrine\ORM\Events;
use Symfony\Component\String\Slugger\SluggerInterface;

#[AsEntityListener(event: Events::prePersist, entity: Post::class)]
#[AsEntityListener(event: Events::preUpdate, entity: Post::class)]
class PostEntityListener
{
    public function __construct(
        private SluggerInterface $slugger,
    ) {
    }

    public function prePersist(Post $post): void
    {
        $post->computeSlug($this->slugger);
    }

    public function preUpdate(Post $post): void
    {
        $post->computeSlug($this->slugger);
    }
}

В модель Post добавляем метод для вычисления slug

public function computeSlug(SluggerInterface $slugger): void
{
    /** @var non-empty-string $title */
    $title = $this->getTitle();

    $this->slug = $slugger->slug($title)->lower()->toString();
}

Теперь при создании и изменении статьи, slug автоматически будет генерироваться по title. Учитывайте, что события  preUpdate не сработает, если Вы ничего не поменяли в модели.

После не забудьте поменять роут в контроллере

#[Route('/post/{slug:post}', name: 'post')]

Теперь в шаблонах twig мы може генерировать ссылки на посты вот таким путем

{{ path('post', { slug: post.slug }) }}