← Back to DevBytes

Migrating from Laravel to Symfony: Step-by-Step Guide

Introduction: Understanding the Migration from Laravel to Symfony

Migrating a web application from Laravel to Symfony is a significant architectural undertaking. Laravel and Symfony are both powerful PHP frameworks, but they differ fundamentally in philosophy, component design, and conventions. Laravel prioritizes developer experience with expressive syntax, facades, and rapid application development patterns. Symfony, on the other hand, emphasizes explicit configuration, reusable components, long-term maintainability, and enterprise-grade decoupled architecture. This guide walks you through a complete, step-by-step migration process, preserving your business logic while systematically translating each Laravel concept into its Symfony counterpart.

This is not a rewrite from scratch — it is a structured translation of routing, controllers, models, views, middleware, authentication, and service bindings from the Laravel ecosystem into Symfony's component-based framework. The goal is to produce a fully functional Symfony application that retains the original behavior, data integrity, and user experience.

Why Migrate from Laravel to Symfony?

🚀 Deploy your AI agent in 10 minutes

Managed Hermes hosting. Zero DevOps. 100M tokens/mo included.

Try it free →

Teams choose this migration for several concrete reasons:

Step-by-Step Migration Guide

Step 1: Audit and Inventory Your Laravel Application

Before writing a single line of Symfony code, you must thoroughly catalog every moving part of your Laravel application. Create an inventory spreadsheet or document covering:

This inventory is your migration roadmap. Without it, you risk missing critical functionality during translation.

Step 2: Initialize a New Symfony Project

Create a fresh Symfony application using the official CLI or Composer. Choose the web application skeleton for a full-stack migration:

# Using Symfony CLI (recommended)
symfony new migrated_project --version="6.4" --webapp

# Or directly with Composer
composer create-project symfony/skeleton:"6.4.*" migrated_project
cd migrated_project
composer require webapp

The --webapp flag installs the complete set of Symfony packs: Doctrine ORM, Twig, Form, Validator, Security, Asset, and more. For a migration targeting Symfony 7.x, replace 6.4 with the appropriate version. The directory structure you will work with includes:

migrated_project/
├── config/
│   ├── routes/
│   ├── services.yaml
│   ├── packages/
│   └── bundles.php
├── src/
│   ├── Controller/
│   ├── Entity/
│   ├── Repository/
│   ├── Service/
│   └── Kernel.php
├── templates/
├── migrations/
├── public/
│   └── index.php
├── bin/
│   └── console
└── .env

Step 3: Map Laravel Concepts to Symfony Equivalents

Understanding the conceptual mapping is critical before diving into code translation. Here is the core mapping table:

+---------------------------------------+----------------------------------------+
| Laravel Concept                        | Symfony Equivalent                     |
+---------------------------------------+----------------------------------------+
| routes/web.php, routes/api.php         | config/routes/ (YAML, PHP, or          |
|                                        | Attributes)                            |
| Controller (base class)                | AbstractController (optional base)     |
| Request helper / request()             | Request object injected via method     |
|                                        | argument or autowiring                  |
| Eloquent Model                         | Doctrine Entity + Repository           |
| Migration (anonymous class)            | Doctrine Migration class               |
| Middleware (HTTP layer)                | Event Subscriber / Kernel Event        |
|                                        | Listener / Security Voter              |
| Blade Template                         | Twig Template                          |
| Service Provider                       | services.yaml + Compiler Passes        |
| Facades                                | Autowired service injection            |
| Artisan Console Commands               | Symfony Console Commands              |
| Laravel Mix / Vite                      | Symfony Asset + Webpack Encore / Vite  |
|                                        | Encore                                 |
| Gates and Policies                     | Security Voters + Authorization        |
|                                        | Checker                                |
| Form Requests (validation)             | Symfony Form Component + Validation    |
|                                        | Constraints                            |
| Notifications / Mail                   | Symfony Notifier / Mailer              |
| Queued Jobs                            | Symfony Messenger                      |
| Scheduled Tasks                        | Symfony Scheduler (or cron + Console)  |
| .env configuration                     | .env + config/packages/                |
+---------------------------------------+----------------------------------------+

Keep this mapping at hand throughout the migration. Each step below implements one row of this table in detail.

Step 4: Migrate Routes and Controllers

Symfony supports multiple routing formats: attributes (the modern approach), YAML, XML, or PHP. For a clean migration mirroring Laravel's declarative routing, use PHP attributes on controller classes.

Laravel Route Example

// routes/web.php
Route::get('/posts', [PostController::class, 'index'])
    ->name('posts.index')
    ->middleware(['auth', 'throttle:60,1']);

Route::post('/posts', [PostController::class, 'store'])
    ->name('posts.store')
    ->middleware(['auth', 'verified']);

Route::get('/posts/{post}', [PostController::class, 'show'])
    ->name('posts.show')
    ->whereNumber('post');

Symfony Controller with Route Attributes

<?php
// src/Controller/PostController.php

namespace App\Controller;

use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;

#[Route('/posts')]
class PostController extends AbstractController
{
    #[Route('', name: 'posts.index', methods: ['GET'])]
    #[IsGranted('ROLE_USER')]
    public function index(): Response
    {
        // Business logic here
        $posts = []; // Will be replaced with repository call
        return $this->render('post/index.html.twig', [
            'posts' => $posts,
        ]);
    }

    #[Route('', name: 'posts.store', methods: ['POST'])]
    #[IsGranted('ROLE_USER')]
    public function store(Request $request): Response
    {
        $data = $request->toArray();
        // Validation and persistence logic
        
        return $this->redirectToRoute('posts.index');
    }

    #[Route('/{id}', name: 'posts.show', methods: ['GET'], requirements: ['id' => '\d+'])]
    public function show(int $id): Response
    {
        // Fetch and display a single post
        $post = []; // Will be replaced with repository call
        
        if (!$post) {
            throw $this->createNotFoundException('Post not found');
        }
        
        return $this->render('post/show.html.twig', [
            'post' => $post,
        ]);
    }
}

Key differences to note: Route parameters are now typed method arguments. Middleware transforms into attributes like #[IsGranted] or custom event listeners. The Request object is injected via method argument rather than accessed through a facade or helper function. Route naming uses a simple string convention (posts.index) identical to Laravel's dot notation.

Step 5: Migrate Models and the Database Layer — Eloquent to Doctrine

This is the most substantial part of the migration. Doctrine uses the Data Mapper pattern, separating persistence logic from domain entities, whereas Eloquent uses the Active Record pattern where models contain both domain and persistence logic.

Laravel Eloquent Model

<?php
// app/Models/Post.php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;

class Post extends Model
{
    use SoftDeletes;

    protected $fillable = [
        'user_id', 'title', 'slug', 'body', 'published_at',
    ];

    protected $casts = [
        'published_at' => 'datetime',
        'is_featured' => 'boolean',
    ];

    public function user(): BelongsTo
    {
        return $this->belongsTo(User::class);
    }

    public function comments(): HasMany
    {
        return $this->hasMany(Comment::class);
    }

    public function scopePublished($query)
    {
        return $query->whereNotNull('published_at')
                     ->where('published_at', '<=', now());
    }

    public function getExcerptAttribute(): string
    {
        return Str::limit($this->body, 200);
    }
}

Symfony Doctrine Entity

<?php
// src/Entity/Post.php

namespace App\Entity;

use App\Repository\PostRepository;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;

#[ORM\Entity(repositoryClass: PostRepository::class)]
#[ORM\Table(name: 'posts')]
#[ORM\HasLifecycleCallbacks]
class Post
{
    #[ORM\Id]
    #[ORM\GeneratedValue(strategy: 'IDENTITY')]
    #[ORM\Column(type: Types::INTEGER)]
    private ?int $id = null;

    #[ORM\ManyToOne(targetEntity: User::class, inversedBy: 'posts')]
    #[ORM\JoinColumn(name: 'user_id', referencedColumnName: 'id', nullable: false)]
    private ?User $user = null;

    #[ORM\Column(type: Types::STRING, length: 255)]
    private string $title;

    #[ORM\Column(type: Types::STRING, length: 255, unique: true)]
    private string $slug;

    #[ORM\Column(type: Types::TEXT)]
    private string $body;

    #[ORM\Column(type: Types::DATETIME_MUTABLE, nullable: true)]
    private ?\DateTimeInterface $publishedAt = null;

    #[ORM\Column(type: Types::BOOLEAN)]
    private bool $isFeatured = false;

    #[ORM\Column(type: Types::DATETIME_MUTABLE, nullable: true)]
    private ?\DateTimeInterface $deletedAt = null;

    #[ORM\OneToMany(mappedBy: 'post', targetEntity: Comment::class, orphanRemoval: true)]
    private Collection $comments;

    #[ORM\Column(type: Types::DATETIME_MUTABLE)]
    private \DateTimeInterface $createdAt;

    #[ORM\Column(type: Types::DATETIME_MUTABLE)]
    private \DateTimeInterface $updatedAt;

    public function __construct()
    {
        $this->comments = new ArrayCollection();
        $this->createdAt = new \DateTimeImmutable();
        $this->updatedAt = new \DateTimeImmutable();
    }

    // Getters and setters...

    public function getId(): ?int
    {
        return $this->id;
    }

    public function getUser(): ?User
    {
        return $this->user;
    }

    public function setUser(?User $user): self
    {
        $this->user = $user;
        return $this;
    }

    public function getTitle(): string
    {
        return $this->title;
    }

    public function setTitle(string $title): self
    {
        $this->title = $title;
        return $this;
    }

    public function getSlug(): string
    {
        return $this->slug;
    }

    public function setSlug(string $slug): self
    {
        $this->slug = $slug;
        return $this;
    }

    public function getBody(): string
    {
        return $this->body;
    }

    public function setBody(string $body): self
    {
        $this->body = $body;
        return $this;
    }

    public function getPublishedAt(): ?\DateTimeInterface
    {
        return $this->publishedAt;
    }

    public function setPublishedAt(?\DateTimeInterface $publishedAt): self
    {
        $this->publishedAt = $publishedAt;
        return $this;
    }

    public function isFeatured(): bool
    {
        return $this->isFeatured;
    }

    public function setIsFeatured(bool $isFeatured): self
    {
        $this->isFeatured = $isFeatured;
        return $this;
    }

    public function getComments(): Collection
    {
        return $this->comments;
    }

    public function addComment(Comment $comment): self
    {
        if (!$this->comments->contains($comment)) {
            $this->comments->add($comment);
            $comment->setPost($this);
        }
        return $this;
    }

    public function removeComment(Comment $comment): self
    {
        if ($this->comments->contains($comment)) {
            $this->comments->removeElement($comment);
            if ($comment->getPost() === $this) {
                $comment->setPost(null);
            }
        }
        return $this;
    }

    public function getDeletedAt(): ?\DateTimeInterface
    {
        return $this->deletedAt;
    }

    public function setDeletedAt(?\DateTimeInterface $deletedAt): self
    {
        $this->deletedAt = $deletedAt;
        return $this;
    }

    #[ORM\PreUpdate]
    public function onPreUpdate(): void
    {
        $this->updatedAt = new \DateTimeImmutable();
    }

    public function getCreatedAt(): \DateTimeInterface
    {
        return $this->createdAt;
    }

    public function getUpdatedAt(): \DateTimeInterface
    {
        return $this->updatedAt;
    }

    // Computed property equivalent to Eloquent accessor
    public function getExcerpt(int $length = 200): string
    {
        if (strlen($this->body) <= $length) {
            return $this->body;
        }
        return substr($this->body, 0, $length) . '...';
    }
}

Doctrine Repository

<?php
// src/Repository/PostRepository.php

namespace App\Repository;

use App\Entity\Post;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;

class PostRepository extends ServiceEntityRepository
{
    public function __construct(ManagerRegistry $registry)
    {
        parent::__construct($registry, Post::class);
    }

    /**
     * Equivalent to Eloquent's scopePublished query scope.
     */
    public function findPublished(): array
    {
        return $this->createQueryBuilder('p')
            ->where('p.publishedAt IS NOT NULL')
            ->andWhere('p.publishedAt <= :now')
            ->setParameter('now', new \DateTimeImmutable())
            ->orderBy('p.publishedAt', 'DESC')
            ->getQuery()
            ->getResult();
    }

    /**
     * Find a single post by slug with eager-loaded user.
     */
    public function findOneBySlugWithUser(string $slug): ?Post
    {
        return $this->createQueryBuilder('p')
            ->leftJoin('p.user', 'u')
            ->addSelect('u')
            ->where('p.slug = :slug')
            ->setParameter('slug', $slug)
            ->getQuery()
            ->getOneOrNullResult();
    }

    /**
     * Save entity (persist + flush).
     */
    public function save(Post $post, bool $flush = true): void
    {
        $this->getEntityManager()->persist($post);
        if ($flush) {
            $this->getEntityManager()->flush();
        }
    }

    /**
     * Remove entity.
     */
    public function remove(Post $post, bool $flush = true): void
    {
        $this->getEntityManager()->remove($post);
        if ($flush) {
            $this->getEntityManager()->flush();
        }
    }
}

Critical migration notes for models: Eloquent's SoftDeletes trait becomes a simple deletedAt nullable column with explicit getter/setter. Query scopes become dedicated repository methods. Accessors become plain PHP methods (getExcerpt()). Relationships are managed through Doctrine's owning/inverse side pattern — always update both sides when setting associations. The $fillable array concept does not exist; instead, use dedicated setter methods and validate input at the controller or form layer.

Database Migrations — Laravel to Doctrine Migrations

Laravel migrations are typically anonymous classes with up() and down() methods using a fluent schema builder. Doctrine migrations use a similar pattern but with raw SQL or a programmatic API.

<?php
// Laravel migration example (for reference)
// database/migrations/2024_01_01_000000_create_posts_table.php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

class CreatePostsTable extends Migration
{
    public function up(): void
    {
        Schema::create('posts', function (Blueprint $table) {
            $table->id();
            $table->foreignId('user_id')->constrained()->cascadeOnDelete();
            $table->string('title');
            $table->string('slug')->unique();
            $table->text('body');
            $table->datetime('published_at')->nullable();
            $table->boolean('is_featured')->default(false);
            $table->softDeletes();
            $table->timestamps();
        });
    }

    public function down(): void
    {
        Schema::dropIfExists('posts');
    }
}

The Doctrine equivalent, generated via php bin/console make:migration after entity mapping is configured:

<?php
// migrations/Version20240101000000.php

declare(strict_types=1);

namespace DoctrineMigrations;

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

final class Version20240101000000 extends AbstractMigration
{
    public function getDescription(): string
    {
        return 'Create posts table';
    }

    public function up(Schema $schema): void
    {
        $this->addSql('CREATE TABLE posts (
            id INT AUTO_INCREMENT NOT NULL,
            user_id INT NOT NULL,
            title VARCHAR(255) NOT NULL,
            slug VARCHAR(255) NOT NULL,
            body LONGTEXT NOT NULL,
            published_at DATETIME DEFAULT NULL,
            is_featured TINYINT(1) NOT NULL DEFAULT 0,
            deleted_at DATETIME DEFAULT NULL,
            created_at DATETIME NOT NULL,
            updated_at DATETIME NOT NULL,
            UNIQUE INDEX UNIQ_POSTS_SLUG (slug),
            INDEX IDX_POSTS_USER_ID (user_id),
            PRIMARY KEY(id)
        ) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci ENGINE = InnoDB');

        $this->addSql('ALTER TABLE posts ADD CONSTRAINT FK_POSTS_USER_ID 
            FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE');
    }

    public function down(Schema $schema): void
    {
        $this->addSql('ALTER TABLE posts DROP FOREIGN KEY FK_POSTS_USER_ID');
        $this->addSql('DROP TABLE posts');
    }
}

You can also generate migrations automatically using php bin/console doctrine:migrations:diff after creating your entity mappings. This command compares the current database schema with your entity metadata and generates the appropriate SQL migration.

Step 6: Migrate Middleware to Symfony Event System

Laravel middleware operates at the HTTP request/response pipeline level. Symfony handles cross-cutting concerns through event listeners and subscribers that hook into the HttpKernel lifecycle. Here's how to translate common middleware patterns.

Laravel Middleware — Logging Requests

<?php
// app/Http/Middleware/LogRequestMiddleware.php

namespace App\Http\Middleware;

use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;

class LogRequestMiddleware
{
    public function handle(Request $request, Closure $next)
    {
        Log::info('Request received', [
            'method' => $request->method(),
            'url' => $request->fullUrl(),
            'ip' => $request->ip(),
        ]);

        return $next($request);
    }
}

Symfony Event Subscriber Equivalent

<?php
// src/EventSubscriber/RequestLoggingSubscriber.php

namespace App\EventSubscriber;

use Psr\Log\LoggerInterface;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpKernel\Event\RequestEvent;
use Symfony\Component\HttpKernel\KernelEvents;

class RequestLoggingSubscriber implements EventSubscriberInterface
{
    public function __construct(
        private LoggerInterface $logger
    ) {}

    public static function getSubscribedEvents(): array
    {
        return [
            KernelEvents::REQUEST => ['onKernelRequest', 100],
        ];
    }

    public function onKernelRequest(RequestEvent $event): void
    {
        // Skip sub-requests (internal forwarding)
        if (!$event->isMainRequest()) {
            return;
        }

        $request = $event->getRequest();

        $this->logger->info('Request received', [
            'method' => $request->getMethod(),
            'url' => $request->getUri(),
            'ip' => $request->getClientIp(),
        ]);
    }
}

Symfony's KernelEvents::REQUEST fires early in the request lifecycle, analogous to Laravel's global middleware. For response-phase logic, use KernelEvents::RESPONSE. For exception handling, use KernelEvents::EXCEPTION. The priority integer (here 100) controls execution order among multiple subscribers — higher numbers run first.

Throttle Middleware Translation

Laravel's built-in throttle middleware can be replicated using Symfony's rate limiter component:

# config/packages/rate_limiter.yaml
framework:
    rate_limiter:
        api_limiter:
            policy: 'sliding_window'
            limit: 60
            interval: '1 minute'
<?php
// src/EventSubscriber/ThrottleSubscriber.php

namespace App\EventSubscriber;

use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpKernel\Event\RequestEvent;
use Symfony\Component\HttpKernel\Exception\TooManyRequestsHttpException;
use Symfony\Component\HttpKernel\KernelEvents;
use Symfony\Component\RateLimiter\RateLimiterFactory;

class ThrottleSubscriber implements EventSubscriberInterface
{
    public function __construct(
        private RateLimiterFactory $apiLimiter
    ) {}

    public static function getSubscribedEvents(): array
    {
        return [
            KernelEvents::REQUEST => ['onKernelRequest', 50],
        ];
    }

    public function onKernelRequest(RequestEvent $event): void
    {
        if (!$event->isMainRequest()) {
            return;
        }

        $request = $event->getRequest();
        
        // Only throttle API routes
        if (!str_starts_with($request->getPathInfo(), '/api/')) {
            return;
        }

        $clientIp = $request->getClientIp();
        $limiter = $this->apiLimiter->create($clientIp);
        
        if ($limiter->consume()->isAccepted() === false) {
            throw new TooManyRequestsHttpException(
                null,
                'Too many requests. Please slow down.'
            );
        }
    }
}

Step 7: Migrate Blade Templates to Twig

Blade and Twig share many syntactic similarities but differ in extensibility mechanisms and some core syntax rules. Here is a systematic translation.

Blade Layout and Template

{{-- resources/views/layouts/app.blade.php --}}
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>@yield('title', 'Default Title')</title>
    @stack('styles')
</head>
<body>
    <header>
        @include('partials.navigation')
    </header>
    
    <main>
        @yield('content')
    </main>
    
    <footer>
        @include('partials.footer')
    </footer>
    
    @stack('scripts')
</body>
</html>

{{-- resources/views/posts/index.blade.php --}}
@extends('layouts.app')

@section('title', 'All Posts')

@section('content')
    <h1>Blog Posts</h1>
    
    @forelse($posts as $post)
        <article>
            <h2>{{ $post->title }}</h2>
            <p>{{ $post->excerpt }}</p>
            <a href="{{ route('posts.show', $post) }}">Read more</a>
        </article>
    @empty
        <p>No posts found.</p>
    @endforelse
    
    {{ $posts->links() }}
@endsection

Twig Equivalent — Base Template

{# templates/layouts/app.html.twig #}
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>{% block title %}Default Title{% endblock %}</title>
    {% block styles %}{% endblock %}
</head>
<body>
    <header>
        {% include 'partials/navigation.html.twig' %}
    </header>
    
    <main>
        {% block content %}{% endblock %}
    </main>
    
    <footer>
        {% include 'partials/footer.html.twig' %}
    </footer>
    
    {% block scripts %}{% endblock %}
</body>
</html>

Twig Equivalent — Content Template

{# templates/post/index.html.twig #}
{% extends 'layouts/app.html.twig' %}

{% block title %}All Posts{% endblock %}

{% block content %}
    <h1>Blog Posts</h1>
    
    {% for post in posts %}
        <article>
            <h2>{{ post.title }}</h2>
            <p>{{ post.getExcerpt(200) }}</p>
            <a href="{{ path('posts.show', {id: post.id}) }}">Read more</a>
        </article>
    {% else %}
        <p>No posts found.</p>
    {% endfor %}
    
    {# Pagination handled via separate Twig extension or manual rendering #}
    {% if pagination is defined %}
        {{ include('partials/pagination.html.twig', {pagination: pagination}) }}
    {% endif %}
{% endblock %}

Key Blade-to-Twig translations:

Step 8: Migrate Service Container Bindings

Laravel's service container bindings in AppServiceProvider become Symfony's services.yaml configuration combined with autowiring. Symfony's dependency injection component automatically resolves constructor type hints, eliminating most manual bindings.

Laravel Service Provider

<?php
// app/Providers/AppServiceProvider.php

namespace App\Providers;

use App\Services\PaymentGatewayInterface;
use App\Services\StripePaymentGateway;
use App\Services\AnalyticsService;
use Illuminate\Support\ServiceProvider;

class AppServiceProvider extends ServiceProvider
{
    public function register(): void
    {
        // Bind interface to concrete implementation
        $this->app->bind(
            PaymentGatewayInterface::class,
            StripePaymentGateway::class
        );

        // Register singleton
        $this->app->singleton(AnalyticsService::class, function ($app) {
            return new AnalyticsService(
                config('services.analytics.api_key'),
                config('services.analytics.endpoint')
            );
        });

        // Tag multiple implementations
        $this->app->tag([
            StripePaymentGateway::class,
            PayPalPaymentGateway::class,
        ], 'payment_gateways');
    }

    public function boot(): void
    {
        // Boot-time configuration
        \Illuminate\Support\Facades\Blade::directive(
            'money', 
            fn ($amount) => ""
        );
    }
}

Symfony Service Configuration

# config/services

🚀 Need a reliable AI agent for your project?

Deploy Hermes Agent in 10 minutes. Managed hosting, zero DevOps.

Get Started — $23.99/mo
← Back to all articles