PHP 8.3+

Laravel pour un développeur Symfony

Le même métier, l'inverse des conventions.

Laravel et Symfony résolvent les mêmes problèmes avec deux philosophies opposées. Symfony vous demande de déclarer ; Laravel décide à votre place et vous laisse redéclarer si besoin. Venant de Symfony, votre difficulté ne sera pas la syntaxe — elle sera de faire confiance à ce que vous ne voyez pas écrit.

01

Le modèle mental

Ce qui change vraiment quand on vient de Symfony

Avant toute syntaxe, il faut comprendre le renversement. En Symfony, l'application est un conteneur de services que vous configurez, et le framework se contente d'appeler votre code. En Laravel, l'application est un ensemble d'objets qui savent se débrouiller : le modèle sait se sauvegarder, la requête sait se valider, la classe sait où trouver ses dépendances.

Ce n'est pas « moins rigoureux » — c'est un autre contrat. Symfony privilégie l'explicite et la traçabilité ; Laravel privilégie la vitesse d'écriture et accepte une part de magie documentée. Les deux tiennent en production. Ce qui vous fera perdre du temps, c'est de chercher en Laravel la déclaration explicite qui n'existe pas.

Les quatre renversements majeurs

SujetSymfonyLaravelConséquence pratique
PersistanceData Mapper (Doctrine)Active Record (Eloquent)Plus d'EntityManager, plus de flush(). $model->save() écrit tout de suite.
DépendancesInjection par constructeurInjection ou façadesCache::get() marche sans rien injecter. C'est un service résolu à l'exécution.
ConfigurationExplicite dans config/Convention, config à la demandeUne table s'appelle articles pour Article sans rien écrire.
RoutingAttributs sur le contrôleurFichier central routes/web.phpLa route ne vit plus à côté de l'action.

Le même enregistrement d'article, dans les deux mondes. Regardez surtout ce qui disparaît à droite : l'EntityManager, le persist, le flush, l'injection du repository.

Symfony · Doctrine
public function __construct(
    private readonly EntityManagerInterface $em,
    private readonly ArticleRepository $articles,
) {}

public function store(Request $request): Response
{
    $article = new Article();
    $article->setTitle($request->request->get('title'));
    $article->setSlug($slugger->slug($article->getTitle()));

    $this->em->persist($article);
    $this->em->flush();

    return $this->redirectToRoute('app_article_index');
}
Laravel · Eloquent
public function store(Request $request)
{
    $article = Article::create([
        'title' => $request->input('title'),
        'slug'  => Str::slug($request->input('title')),
    ]);

    return to_route('articles.index');
}
Piège de départEn Doctrine, tant que vous n'avez pas appelé flush(), rien n'est en base — vous pouvez modifier dix entités et tout écrire d'un coup dans une transaction implicite. En Eloquent, save() écrit immédiatement. Si vous enchaînez cinq save() qui doivent réussir ensemble, vous devez ouvrir la transaction vous-même avec DB::transaction(). C'est l'erreur numéro un des développeurs Doctrine qui passent à Eloquent.

Les façades : ce que c'est vraiment

Une façade Laravel n'est pas une classe statique. C'est un proxy qui résout un service dans le conteneur au moment de l'appel. Cache::get('x') équivaut exactement à app('cache')->get('x'). Le conteneur existe, l'injection existe, elles sont juste optionnelles.

Les trois façons d'obtenir le même service
// 1. Façade — pratique, testable via Cache::fake()
use Illuminate\Support\Facades\Cache;
Cache::put('stats', $data, 3600);

// 2. Helper — la même chose en fonction globale
cache()->put('stats', $data, 3600);

// 3. Injection — identique à Symfony, préférable dans un service métier
use Illuminate\Contracts\Cache\Repository as CacheContract;

public function __construct(private readonly CacheContract $cache) {}

Conseil venant de votre profil : gardez l'injection par constructeur dans vos services métier, exactement comme en Symfony. Utilisez les façades dans les contrôleurs, commandes, jobs et tests, là où le confort prime. Vous aurez un code qui reste lisible pour un œil Symfony.

À retenirLe conteneur de Laravel fait de l'autowiring comme celui de Symfony. Un type-hint dans un constructeur de contrôleur, de job ou de commande est résolu automatiquement. Vous n'avez pas perdu l'injection de dépendances, elle est juste moins visible.
02

Installation & cycle de vie

Du premier composer create-project à la réponse HTTP

Trois chemins, comme en Symfony. La différence : Laravel n'a pas d'équivalent strict au binaire symfony. Herd joue ce rôle sur macOS et Windows, Sail le fait via Docker, et artisan serve suffit largement pour apprendre.

composer create-project laravel/laravel mon-projetLa méthode la plus fiable, sans dépendance au PATH global. L'équivalent de composer create-project symfony/skeleton.
laravel new mon-projet --react --pestAssistant de l'installeur global : starter kit, base de données, framework de test.
php artisan serveServeur intégré sur 127.0.0.1:8000. Équivalent de symfony server:start, en plus rudimentaire.
composer run devLance en parallèle serveur, worker de queue, logs et Vite. Le script est fourni par défaut.
php artisan aboutPanorama de l'application : drivers, environnement, caches. Le cousin de bin/console about.

Le cycle d'une requête, comparé

Les deux frameworks suivent le même schéma : point d'entrée unique, kernel, middleware, contrôleur, réponse. Le vocabulaire diffère, la mécanique non.

ÉtapeSymfonyLaravel
Point d'entréepublic/index.phppublic/index.php
Amorçagesrc/Kernel.phpbootstrap/app.php
Chaîne d'interceptionEventListener sur kernel.requestMiddleware (pile, ordre explicite)
Résolution de la routeRouter + attributsRouter + routes/web.php
Injection du contrôleurArgumentResolverService container + Route Model Binding
RéponseObjet ResponseObjet Response
Après réponsekernel.terminateMiddleware terminate()

Le fichier bootstrap/app.php est le point le plus déroutant : depuis Laravel 11, il concentre le routing, les middleware et la gestion des exceptions. C'est à la fois votre Kernel.php, votre config/packages/framework.yaml et votre gestion d'erreurs.

bootstrap/app.php — le centre de configuration
return Application::configure(basePath: dirname(__DIR__))
    ->withRouting(
        web: __DIR__.'/../routes/web.php',
        commands: __DIR__.'/../routes/console.php',
        health: '/up',
    )
    ->withMiddleware(function (Middleware $middleware) {
        $middleware->alias([
            'admin' => \App\Http\Middleware\EnsureUserIsAdmin::class,
        ]);
        $middleware->web(append: [TrackVisits::class]);
    })
    ->withExceptions(function (Exceptions $exceptions) {
        $exceptions->render(function (NotFoundHttpException $e) {
            return response()->view('errors.404', status: 404);
        });
    })
    ->create();

Structure des dossiers

LaravelÉquivalent SymfonyRemarque
app/Models/src/Entity/Le modèle contient aussi les requêtes
app/Http/Controllers/src/Controller/Identique
app/Http/Requests/contraintes #[Assert]Classe dédiée par formulaire
app/Http/Middleware/EventSubscriberPile ordonnée, pas de priorités numériques
app/Providers/services.yaml + bundlesLe point d'enregistrement des services
database/migrations/migrations/Écrites à la main, pas générées par diff
resources/views/templates/Blade au lieu de Twig
routes/attributs de routeFichiers centraux
config/config/packages/Fichiers PHP, publiés à la demande
storage/var/Logs, cache, uploads
Différence structurelleIl n'y a pas de bundles en Laravel. Un paquet s'installe avec Composer et s'enregistre tout seul via un ServiceProvider découvert automatiquement. Vous ne trouverez pas d'équivalent à config/bundles.php, et c'est normal.
03

Artisan

bin/console, avec d'autres noms

Artisan est à Laravel ce que bin/console est à Symfony. Le réflexe à conserver : list et help plutôt que la mémoire. La grande différence est l'absence d'équivalent riche aux debug:* — Laravel mise sur le fait qu'il y a moins de configuration à inspecter.

php artisan listToutes les commandes. Équivalent exact de bin/console list.
php artisan help make:modelArguments et options d'une commande.
php artisan aboutVersion, drivers, environnement.
php artisan tinkerREPL avec l'application bootée. Symfony n'a pas d'équivalent natif — c'est un des vrais plus de Laravel.
php artisan route:list --except-vendorLe cousin de debug:router.
php artisan optimize:clearVide config, routes, vues et cache. Le réflexe cache:clear.

Tinker : à adopter tout de suite

C'est l'outil qui va le plus accélérer votre apprentissage d'Eloquent. Vous tapez une requête, vous voyez le résultat immédiatement, sans écrire de contrôleur ni de test. En Symfony vous seriez passé par doctrine:query:dql ou un test jetable.

Session tinker typique
php artisan tinker

>>> Article::count()
= 42

>>> Article::where('status', 'published')->latest()->first()
= App\Models\Article {#5234 id: 17, title: "Bonjour", ...}

>>> $a = Article::factory()->create(['title' => 'Test'])
>>> $a->author->email
= "amar@exemple.fr"

>>> Article::with('author')->get()->pluck('author.email')->unique()

// Voir le SQL sans l'exécuter
>>> Article::where('views', '>', 100)->toSql()
= "select * from `articles` where `views` > ?"

Table de correspondance des commandes

SymfonyLaravel
bin/console cache:clearartisan optimize:clear
bin/console debug:routerartisan route:list
bin/console make:entityartisan make:model -m
bin/console make:crudartisan make:model --all
bin/console make:migrationartisan make:migration (vide, à écrire)
doctrine:migrations:migrateartisan migrate
doctrine:fixtures:loadartisan db:seed
messenger:consume asyncartisan queue:work
bin/console make:commandartisan make:command
debug:autowiringpas d'équivalent
debug:containerpas d'équivalent
pas d'équivalentartisan tinker

Les générateurs

CommandeGénère
make:model Articlele modèle seul
make:model Article -mfscmodèle + migration + factory + seeder + contrôleur
make:model Article --alltout, y compris policy et form requests
make:controller X --resourceles 7 méthodes CRUD
make:controller X --apiCRUD sans create ni edit
make:request StoreArticleRequestvalidation dédiée
make:resource ArticleResourcetransformateur JSON
make:policy ArticlePolicy --model=Articleautorisations
make:job · make:event · make:listenerasynchrone et événements
make:observer X --model=Articlehooks du cycle de vie du modèle
make:test ArticleTesttest de fonctionnalité
À retenirL'option -mfsc est le raccourci que vous taperez le plus souvent : migration, factory, seeder, controller. En une commande vous avez l'ossature complète d'une ressource.
04

Routing

Le fichier central plutôt que les attributs

C'est le premier vrai dépaysement visuel. Vos routes ne sont plus collées à l'action : elles vivent toutes dans routes/web.php. L'avantage est réel — une seule lecture donne la carte complète de l'application. L'inconvénient aussi : le fichier grossit vite, et il faut discipliner les groupes.

Symfony · attributs
#[Route('/articles', name: 'app_article_')]
class ArticleController extends AbstractController
{
    #[Route('', name: 'index', methods: ['GET'])]
    public function index(): Response {}

    #[Route('/{id}', name: 'show', methods: ['GET'],
            requirements: ['id' => Requirement::DIGITS])]
    public function show(int $id): Response {}
}
Laravel · fichier central
// routes/web.php
Route::prefix('articles')
    ->name('articles.')
    ->group(function () {
        Route::get('/', [ArticleController::class, 'index'])
            ->name('index');

        Route::get('/{id}', [ArticleController::class, 'show'])
            ->name('show')
            ->whereNumber('id');
    });

Les routes de ressource

Laravel a une convention CRUD très forte. Une seule ligne déclare sept routes nommées et les branche sur sept méthodes attendues. C'est l'équivalent conceptuel de make:crud, mais imposé à l'exécution plutôt que généré une fois.

routes/web.php
Route::resource('articles', ArticleController::class);

// Restreindre
Route::resource('articles', ArticleController::class)->only(['index', 'show']);
Route::resource('articles', ArticleController::class)->except(['destroy']);

// API : pas de create ni edit (ce sont des pages de formulaire)
Route::apiResource('articles', ArticleController::class);

// Imbriquée : /articles/{article}/comments
Route::resource('articles.comments', CommentController::class)->shallow();

// Groupes
Route::middleware(['auth', 'verified'])
    ->prefix('admin')
    ->name('admin.')
    ->group(function () {
        Route::resource('articles', AdminArticleController::class);
    });
Verbe & URIMéthodeNom généré
GET /articlesindexarticles.index
GET /articles/createcreatearticles.create
POST /articlesstorearticles.store
GET /articles/{article}showarticles.show
GET /articles/{article}/editeditarticles.edit
PUT/PATCH /articles/{article}updatearticles.update
DELETE /articles/{article}destroyarticles.destroy

Route Model Binding

L'équivalent direct de #[MapEntity], mais activé par défaut : si le paramètre de route porte le même nom que la variable type-hintée, Laravel charge le modèle et lève un 404 s'il n'existe pas. Aucun attribut à écrire.

Liaison implicite
// Route::get('/articles/{article}', ...)
public function show(Article $article)   // chargé par id, 404 automatique
{
    return view('articles.show', compact('article'));
}

// Par une autre colonne — deux façons
// 1. dans la route
Route::get('/articles/{article:slug}', [ArticleController::class, 'show']);

// 2. sur le modèle, pour toute l'application
public function getRouteKeyName(): string
{
    return 'slug';
}

// Avec scoping : le commentaire doit appartenir à l'article
Route::get('/articles/{article}/comments/{comment}', ...)->scopeBindings();
php artisan route:listToutes les routes avec middleware et nom.
php artisan route:list --path=api --except-vendorFiltre par URI, masque les routes des paquets.
php artisan route:cacheCompile les routes. Production uniquement, incompatible avec les closures.
php artisan route:clearUne route fantôme après modification ? C'est souvent ça.
Générer des URLs
route('articles.show', $article);          // ≈ generateUrl()
route('articles.index', ['page' => 2]);
url('/contact');
to_route('articles.index');                // redirection depuis un contrôleur

// Blade
{{ route('articles.show', $article) }}
05

Contrôleurs & requêtes

AbstractController vs Controller

Très proche de ce que vous connaissez. L'injection par constructeur fonctionne, l'injection par méthode aussi, et la classe de base offre les mêmes raccourcis. Le principal changement : l'objet Request se manipule différemment et porte lui-même la validation.

Contrôleur de ressource complet
namespace App\Http\Controllers;

use App\Http\Requests\StoreArticleRequest;
use App\Models\Article;

class ArticleController extends Controller
{
    // L'injection par constructeur fonctionne comme en Symfony
    public function __construct(
        private readonly ArticleService $service,
    ) {}

    public function index()
    {
        $articles = Article::with('author')
            ->latest()
            ->paginate(15);

        return view('articles.index', compact('articles'));
    }

    // StoreArticleRequest valide AVANT d'entrer dans la méthode
    public function store(StoreArticleRequest $request)
    {
        $article = Article::create($request->validated());

        return to_route('articles.show', $article)
            ->with('success', 'Article publié.');
    }

    public function show(Article $article)          // liaison implicite
    {
        return view('articles.show', compact('article'));
    }

    public function destroy(Article $article)
    {
        $this->authorize('delete', $article);       // ≈ denyAccessUnlessGranted
        $article->delete();

        return to_route('articles.index');
    }
}
SymfonyLaravel
$this->render()view()
$this->json()response()->json()
$this->redirectToRoute()to_route() ou redirect()->route()
$this->addFlash()->with('success', …) ou session()->flash()
$this->getUser()auth()->user() ou $request->user()
$this->denyAccessUnlessGranted()$this->authorize()
$this->createNotFoundException()abort(404)
$this->file()response()->download()

Lire la requête

L'objet Request
$request->input('title', 'défaut');    // POST ou GET, indifféremment
$request->query('page');               // GET seulement
$request->only(['title', 'body']);
$request->except(['_token']);
$request->has('tags');                 // le champ existe
$request->filled('search');            // existe ET n'est pas vide
$request->boolean('published');        // "1", "true", "on" → true
$request->date('published_at');        // instance Carbon
$request->integer('page');
$request->enum('status', Status::class);
$request->file('cover')->store('covers', 'public');
$request->user();
$request->ip();
$request->wantsJson();
À retenir$request->input() lit indifféremment le corps et la query string, contrairement à Symfony où $request->request et $request->query sont séparés. Pratique, mais soyez explicite avec query() quand la distinction compte pour la sécurité.

Contrôleur invocable

L'équivalent de vos contrôleurs à action unique. Une seule méthode __invoke(), référencée par la classe seule dans la route.

php artisan make:controller PublishArticle --invokable
class PublishArticle extends Controller
{
    public function __invoke(Article $article)
    {
        $article->update(['published_at' => now()]);

        return back()->with('success', 'Article publié.');
    }
}

// routes/web.php
Route::post('/articles/{article}/publish', PublishArticle::class);
06

Blade

Twig, en syntaxe PHP

Blade compile en PHP natif, comme Twig. L'échappement automatique fonctionne de la même façon : {{ }} échappe, {!! !!} n'échappe pas. La différence de fond : Blade vous laisse écrire du PHP arbitraire, Twig vous l'interdit. C'est une liberté à discipliner.

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

{% block title %}Articles{% endblock %}

{% block body %}
  {% for article in articles %}
    <h2>{{ article.title }}</h2>
    <p>{{ article.body|striptags|slice(0, 120) }}</p>
  {% else %}
    <p>Aucun article.</p>
  {% endfor %}

  {% if is_granted('ROLE_ADMIN') %}
    <a href="{{ path('app_article_new') }}">Nouveau</a>
  {% endif %}
{% endblock %}
Blade
@extends('layouts.app')

@section('title', 'Articles')

@section('content')
  @forelse ($articles as $article)
    <h2>{{ $article->title }}</h2>
    <p>{{ Str::limit(strip_tags($article->body), 120) }}</p>
  @empty
    <p>Aucun article.</p>
  @endforelse

  @can('create', App\Models\Article::class)
    <a href="{{ route('articles.create') }}">Nouveau</a>
  @endcan
@endsection

Correspondance des directives

TwigBlade
{% extends %}@extends
{% block x %}…{% endblock %}@section('x')…@endsection
{{ block('x') }} / {% block %}@yield('x')
{{ parent() }}@parent
{% include %}@include
{% for … else %}@forelse … @empty
{{ loop.index }}{{ $loop->iteration }}
is_granted()@can / @cannot
app.userauth()->user()
{{ dump() }}@dump / @dd
{{ path() }}{{ route() }}
{{ asset() }}{{ asset() }}
form themes@csrf + composants maison

Composants Blade

L'équivalent des composants Twig UX, mais intégrés au cœur depuis longtemps. C'est la bonne façon d'écrire du Blade moderne : préférez les composants aux @include.

resources/views/components/alert.blade.php
@props(['type' => 'info', 'dismissible' => false])

<div {{ $attributes->merge(['class' => 'alert alert-'.$type]) }}>
    @if ($title ?? false)
        <strong>{{ $title }}</strong>
    @endif

    {{ $slot }}

    @if ($dismissible)
        <button type="button" data-dismiss>&times;</button>
    @endif
</div>
Utilisation
<x-alert type="error" dismissible>
    <x-slot:title>Échec de l'enregistrement</x-slot:title>
    Vérifiez les champs en rouge.
</x-alert>

{{-- Attribut dynamique : préfixe : --}}
<x-alert :type="$niveau" :message="$erreur" />

Formulaires

Le pattern standard
<form method="POST" action="{{ route('articles.update', $article) }}">
    @csrf
    @method('PUT')

    <input name="title" value="{{ old('title', $article->title) }}">
    @error('title')
        <p class="error">{{ $message }}</p>
    @enderror

    <button>Enregistrer</button>
</form>
À savoirLaravel n'a pas de composant Form équivalent à celui de Symfony. Pas de ArticleType, pas de handleRequest(), pas de thème de formulaire. Vous écrivez le HTML à la main et la validation vit dans une Form Request. C'est plus verbeux au début, mais beaucoup plus prévisible — et vous ne vous battrez plus jamais contre un thème de formulaire.
php artisan view:cachePrécompile toutes les vues. Étape de déploiement.
php artisan view:clearUne vue qui ne se met pas à jour ? Videz le cache.
07

Eloquent — les bases

Le plus gros écart avec Doctrine

Prenez votre temps sur cette section : c'est là que se joue votre productivité en Laravel. Un modèle Eloquent est une classe qui est une ligne de table et qui sait interroger sa table. Il n'y a ni entité anémique, ni repository, ni EntityManager.

Le même concept, deux fichiers contre un. Notez qu'Eloquent ne déclare aucune propriété : les colonnes sont découvertes à l'exécution depuis le schéma de la table.

Symfony · Entity + Repository
#[ORM\Entity(repositoryClass: ArticleRepository::class)]
class Article
{
    #[ORM\Id, ORM\GeneratedValue, ORM\Column]
    private ?int $id = null;

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

    #[ORM\Column(nullable: true)]
    private ?\DateTimeImmutable $publishedAt = null;

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

class ArticleRepository extends ServiceEntityRepository
{
    public function findPublished(): array
    {
        return $this->createQueryBuilder('a')
            ->andWhere('a.publishedAt IS NOT NULL')
            ->getQuery()->getResult();
    }
}
Laravel · Model
class Article extends Model
{
    use HasFactory, SoftDeletes;

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

    protected function casts(): array
    {
        return [
            'published_at' => 'datetime',
            'meta'         => 'array',
            'status'       => Status::class,
        ];
    }

    // Le "repository" est un scope sur le modèle
    public function scopePublished($query)
    {
        return $query->whereNotNull('published_at');
    }
}

// Usage : Article::published()->get();

Les conventions à connaître par cœur

ÉlémentConventionPour surcharger
Tablepluriel snake_case du modèle : Articlearticlesprotected $table = 'art';
Clé primaireid, entier auto-incrémentéprotected $primaryKey = 'uuid';
Timestampscreated_at et updated_at gérés seulspublic $timestamps = false;
Clé étrangèreuser_id pour une relation vers User2ᵉ argument de belongsTo()
Table pivotles deux noms au singulier, ordre alphabétique : article_tag2ᵉ argument de belongsToMany()
Piège Doctrine → Eloquent$fillable n'est pas de la validation, c'est une protection contre l'assignation de masse. Si un champ n'y figure pas, Article::create() l'ignore silencieusement — pas d'erreur, la colonne reste vide et vous cherchez pendant vingt minutes. Premier réflexe quand une valeur ne s'enregistre pas : vérifier $fillable.

Lire

Requêtes de lecture
Article::all();                              // ≈ findAll()
Article::find(1);                            // ≈ find()
Article::findOrFail(1);                      // lève un 404
Article::firstWhere('slug', $slug);          // ≈ findOneBy()
Article::where('status', 'published')->get();
Article::where('views', '>', 100)->orderByDesc('views')->take(5)->get();
Article::whereIn('user_id', [1, 2, 3])->get();
Article::whereBetween('created_at', [$debut, $fin])->get();
Article::whereNull('published_at')->get();
Article::whereRelation('author', 'active', true)->get();

// Pagination — inclut les liens, contrairement à Doctrine
$articles = Article::paginate(15);
$articles = Article::simplePaginate(15);     // sans total, plus rapide

// Agrégats
Article::count();
Article::sum('views');
Article::avg('rating');
Article::exists();
Article::pluck('title', 'id');               // ['1' => 'Bonjour', …]

// Le SQL généré, sans exécuter
Article::where('views', '>', 100)->toSql();

Écrire

Création et mise à jour
// Création directe — écrit immédiatement
$article = Article::create([
    'title' => 'Bonjour',
    'body'  => '…',
]);

// En deux temps
$article = new Article();
$article->title = 'Bonjour';
$article->save();

// Mise à jour
$article->update(['title' => 'Corrigé']);
$article->title = 'Corrigé';
$article->save();

// Idempotent — très pratique pour les imports
Article::updateOrCreate(
    ['slug' => $slug],                  // critères de recherche
    ['title' => $titre, 'body' => $c],  // valeurs à écrire
);
Article::firstOrCreate(['slug' => $slug]);

// Suppression
$article->delete();
Article::destroy([1, 2, 3]);
$article->restore();                    // avec SoftDeletes
$article->forceDelete();
Article::withTrashed()->get();
Article::onlyTrashed()->get();
TransactionsPuisque chaque save() écrit tout de suite, vous perdez la transaction implicite du flush() Doctrine. Dès que plusieurs écritures doivent réussir ensemble, enveloppez-les : DB::transaction(function () { … });. Le rollback est automatique si une exception est levée.
Transactions
use Illuminate\Support\Facades\DB;

DB::transaction(function () use ($data) {
    $order = Order::create($data['order']);
    $order->items()->createMany($data['items']);
    Stock::decrementFor($order);
});

// Contrôle manuel
DB::beginTransaction();
try {
    // …
    DB::commit();
} catch (\Throwable $e) {
    DB::rollBack();
    throw $e;
}

Gros volumes

L'équivalent du iterate() ou du clear() périodique de Doctrine. Sans cela, vous chargez cent mille modèles en mémoire.

Traitement par lots
Article::chunk(200, function ($articles) {
    foreach ($articles as $article) { … }
});

// Plus sûr si vous modifiez la colonne de tri pendant le parcours
Article::chunkById(200, fn ($lot) => …);

// Générateur paresseux — syntaxe de collection, mémoire constante
Article::lazy()->each(fn ($a) => …);

// Curseur PDO — le plus économe, une seule requête
foreach (Article::cursor() as $article) { … }
08

Eloquent — relations

Et le N+1, votre vieil ennemi

Les relations Eloquent sont des méthodes qui renvoient un objet de relation. Deux façons de les utiliser : appelée comme propriété ($article->author) elle renvoie le résultat ; appelée comme méthode ($article->author()) elle renvoie un query builder que vous pouvez continuer à composer.

Déclaration des relations
class Article extends Model
{
    // ManyToOne — la clé étrangère est ici
    public function author(): BelongsTo
    {
        return $this->belongsTo(User::class, 'user_id');
    }

    // OneToMany — la clé étrangère est en face
    public function comments(): HasMany
    {
        return $this->hasMany(Comment::class);
    }

    // ManyToMany — table pivot article_tag
    public function tags(): BelongsToMany
    {
        return $this->belongsToMany(Tag::class)
            ->withTimestamps()
            ->withPivot('position');
    }

    // OneToOne
    public function seo(): HasOne
    {
        return $this->hasOne(SeoMeta::class);
    }

    // Polymorphe — un commentaire sur n'importe quoi
    public function comments(): MorphMany
    {
        return $this->morphMany(Comment::class, 'commentable');
    }

    // Raccourci HasManyThrough : les commentaires de tous mes articles
    public function comments(): HasManyThrough
    {
        return $this->hasManyThrough(Comment::class, Article::class);
    }
}
DoctrineEloquent
#[ORM\ManyToOne]belongsTo()
#[ORM\OneToMany(mappedBy:)]hasMany()
#[ORM\OneToOne]hasOne() / belongsTo()
#[ORM\ManyToMany]belongsToMany()
#[ORM\JoinTable]2ᵉ argument de belongsToMany()
cascade: ['persist']$model->relation()->create()
orphanRemoval: true->delete() explicite ou observer
Discriminator maprelations polymorphes morphTo()

Le N+1 : le même problème, une autre solution

Vous connaissez le symptôme : une boucle sur cent articles qui déclenche cent-et-une requêtes. En Doctrine vous corrigez avec un addSelect() dans le QueryBuilder. En Eloquent, c'est with() — l'eager loading. Le mécanisme diffère : Eloquent ne fait pas de JOIN, il fait une seconde requête WHERE IN et recolle les résultats en PHP.

Le problème et le remède
// ❌ 1 + 100 requêtes
$articles = Article::all();
foreach ($articles as $article) {
    echo $article->author->name;      // une requête à chaque tour
}

// ✅ 2 requêtes au total
$articles = Article::with('author')->get();

// Relations imbriquées
Article::with('comments.author')->get();

// Plusieurs relations
Article::with(['author', 'tags', 'comments' => fn ($q) => $q->latest()->limit(3)])->get();

// Compter sans charger
Article::withCount('comments')->get();     // $article->comments_count
Article::withSum('orders', 'total')->get();

// Charger après coup, sur une collection déjà en mémoire
$articles->load('tags');
$articles->loadMissing('author');          // seulement si absent
Le réglage qui change toutAjoutez ceci dans AppServiceProvider::boot() : Model::preventLazyLoading(! app()->isProduction());. En local, toute relation chargée paresseusement lève désormais une exception. Vous ne livrerez plus jamais un N+1 sans le savoir — c'est l'équivalent d'un doctrine.orm.auto_generate_proxy_classes bien réglé, en beaucoup plus strict.

Filtrer sur les relations

L'équivalent d'un JOIN + WHERE en DQL
// A au moins un commentaire
Article::has('comments')->get();
Article::has('comments', '>=', 3)->get();

// Avec une condition sur la relation
Article::whereHas('tags', fn ($q) => $q->where('slug', 'php'))->get();
Article::whereRelation('author', 'active', true)->get();   // raccourci

// La négation
Article::doesntHave('comments')->get();
Article::whereDoesntHave('tags', fn ($q) => $q->where('hidden', true))->get();

Écrire à travers une relation

Les méthodes qui n'ont pas d'équivalent Doctrine direct
// La clé étrangère est remplie automatiquement
$user->articles()->create(['title' => 'Bonjour']);
$article->comments()->createMany([...]);

// Many-to-many
$article->tags()->attach([1, 3]);
$article->tags()->attach(2, ['position' => 5]);   // avec données de pivot
$article->tags()->detach(3);
$article->tags()->detach();                        // tout retirer
$article->tags()->sync([1, 2]);                    // remplace intégralement
$article->tags()->syncWithoutDetaching([4]);       // ajoute sans retirer
$article->tags()->toggle([1, 5]);

// ManyToOne
$comment->article()->associate($article);
$comment->article()->dissociate();
$comment->save();

// Accéder aux données du pivot
foreach ($article->tags as $tag) {
    echo $tag->pivot->position;
}

Accesseurs, mutateurs, scopes

La logique qui vit sur le modèle
use Illuminate\Database\Eloquent\Casts\Attribute;

class Article extends Model
{
    // Accesseur : $article->excerpt
    protected function excerpt(): Attribute
    {
        return Attribute::get(fn () => Str::limit(strip_tags($this->body), 140));
    }

    // Accesseur + mutateur sur une colonne existante
    protected function title(): Attribute
    {
        return Attribute::make(
            get: fn (string $value) => ucfirst($value),
            set: fn (string $value) => trim($value),
        );
    }

    // Scope local : Article::published()->get()
    public function scopePublished($query)
    {
        return $query->whereNotNull('published_at')
                     ->where('published_at', '<=', now());
    }

    // Scope paramétré : Article::ofStatus('draft')->get()
    public function scopeOfStatus($query, string $status)
    {
        return $query->where('status', $status);
    }
}

Les scopes remplacent vos méthodes de repository. La différence philosophique : en Doctrine, la requête vit dans une classe séparée du modèle ; en Eloquent, elle vit sur le modèle. Si un scope devient long ou touche plusieurs modèles, sortez-le dans une classe de service — Laravel ne vous l'interdit pas.

09

Migrations, factories, seeders

Écrites à la main, pas générées par diff

La différence qui surprend le plusIl n'y a pas de make:migration par diff en Laravel. Doctrine compare vos entités au schéma et écrit le SQL pour vous ; Laravel génère un fichier vide et vous écrivez la structure. En contrepartie, la migration est la seule source de vérité du schéma — le modèle ne décrit rien. Beaucoup de développeurs Doctrine trouvent cela plus sain une fois l'habitude prise.
database/migrations/…_create_articles_table.php
return new class extends Migration
{
    public function up(): void
    {
        Schema::create('articles', function (Blueprint $table) {
            $table->id();
            $table->foreignId('user_id')->constrained()->cascadeOnDelete();
            $table->string('title');
            $table->string('slug')->unique();
            $table->text('body');
            $table->enum('status', ['draft', 'published'])->default('draft');
            $table->json('meta')->nullable();
            $table->decimal('price', 8, 2)->default(0);
            $table->unsignedInteger('views')->default(0);
            $table->timestamp('published_at')->nullable();
            $table->timestamps();          // created_at + updated_at
            $table->softDeletes();         // deleted_at

            $table->index(['status', 'published_at']);
        });
    }

    public function down(): void
    {
        Schema::dropIfExists('articles');
    }
};
Modifier une table existante
Schema::table('articles', function (Blueprint $table) {
    $table->string('subtitle')->nullable()->after('title');
    $table->renameColumn('body', 'content');
    $table->dropColumn('legacy_field');
    $table->dropConstrainedForeignId('category_id');
});
php artisan make:migration create_articles_tableLe nom détermine le squelette généré.
php artisan make:migration add_status_to_articles_table --table=articlesMigration de modification.
php artisan migratedoctrine:migrations:migrate
php artisan migrate --pretendAffiche le SQL sans l'exécuter.
php artisan migrate:statusdoctrine:migrations:status
php artisan migrate:rollback --step=1Annule le dernier lot.
php artisan migrate:fresh --seedSupprime toutes les tables puis rejoue tout. Jamais en production.
php artisan migrate --forceObligatoire en production, sans confirmation interactive.
php artisan db:show --countsTables, tailles, nombre de lignes.
php artisan db:table articlesStructure détaillée d'une table.

Factories : ce que les fixtures Doctrine ne font pas

C'est un des vrais avantages de Laravel. Une factory décrit comment fabriquer un modèle plausible ; vous l'utilisez dans les seeders et dans les tests, avec des états composables. L'équivalent Symfony le plus proche est Zenstruck Foundry, qui s'en inspire directement.

database/factories/ArticleFactory.php
class ArticleFactory extends Factory
{
    public function definition(): array
    {
        return [
            'title'   => fake()->sentence(),
            'slug'    => fake()->unique()->slug(),
            'body'    => fake()->paragraphs(4, true),
            'user_id' => User::factory(),     // crée l'auteur à la volée
        ];
    }

    // État réutilisable
    public function published(): static
    {
        return $this->state(['published_at' => now()]);
    }

    public function withComments(int $n = 3): static
    {
        return $this->has(Comment::factory()->count($n));
    }
}
Utilisation
Article::factory()->create();
Article::factory(50)->published()->create();
Article::factory()->withComments(5)->create(['title' => 'Fixe']);
Article::factory()->make();                    // sans persister

// Relations dans les deux sens
User::factory()->has(Article::factory()->count(3))->create();
Article::factory()->for(User::factory()->state(['role' => 'admin']))->create();
database/seeders/DatabaseSeeder.php
public function run(): void
{
    $admin = User::factory()->create([
        'email' => 'admin@exemple.fr',
        'role'  => 'admin',
    ]);

    Article::factory(50)
        ->published()
        ->for($admin)
        ->create();

    $this->call([TagSeeder::class, SettingSeeder::class]);
}
php artisan db:seeddoctrine:fixtures:load, sans vider la base.
php artisan db:seed --class=ArticleSeederUn seeder précis.
php artisan migrate:fresh --seedLe combo de développement quotidien.
À retenirPour du contenu francophone, réglez APP_FAKER_LOCALE=fr_FR dans le .env. C'est le même Faker que celui que vous utilisez déjà dans vos fixtures Symfony.
10

Validation

Form Requests au lieu de contraintes

Changement de porteur : en Symfony les règles vivent sur l'entité via des attributs, en Laravel elles vivent sur la requête. Conséquence directe : deux formulaires touchant le même modèle peuvent avoir des règles différentes sans acrobatie de groupes de validation.

Symfony · contraintes sur l'entité
class Article
{
    #[Assert\NotBlank]
    #[Assert\Length(min: 3, max: 255)]
    private string $title;

    #[Assert\NotBlank(groups: ['publication'])]
    private string $body;
}

// Contrôleur
$form = $this->createForm(ArticleType::class, $article);
$form->handleRequest($request);

if ($form->isSubmitted() && $form->isValid()) { … }
Laravel · Form Request
class StoreArticleRequest extends FormRequest
{
    public function authorize(): bool
    {
        return $this->user()->can('create', Article::class);
    }

    public function rules(): array
    {
        return [
            'title' => ['required', 'string', 'min:3', 'max:255'],
            'body'  => ['required', 'min:50'],
        ];
    }
}

// Contrôleur : la validation a déjà eu lieu
public function store(StoreArticleRequest $request)
{
    Article::create($request->validated());
}

Le mécanisme est important à comprendre : le type-hint StoreArticleRequest déclenche la validation avant l'entrée dans la méthode. Si elle échoue, Laravel redirige automatiquement en arrière avec les erreurs en session (ou renvoie un 422 JSON si la requête attend du JSON). Vous n'écrivez aucun if.

Validation inline

Pour les cas simples
public function store(Request $request)
{
    $data = $request->validate([
        'title'   => 'required|string|max:255',
        'slug'    => ['required', Rule::unique('articles')->ignore($article)],
        'tags'    => 'array',
        'tags.*'  => 'exists:tags,id',
        'cover'   => 'nullable|image|max:2048',
    ]);

    Article::create($data);
}

Les règles courantes

LaravelÉquivalent SymfonyNote
requiredNotBlank
nullablechamp nullablesans ça, null échoue sur les autres règles
sometimesgroupes de validationvalider seulement si le champ est présent
email:rfc,dnsEmail(mode: 'strict')
min:3 / max:255Lengthsur un nombre, borne la valeur ; sur un fichier, la taille
confirmedRepeatedTypeexige un champ xxx_confirmation
unique:users,emailUniqueEntity
exists:tags,idpas d'équivalent directvérifie la présence en base
in:draft,publishedChoice
Rule::enum(Status::class)Choice(callback:)
after:todayGreaterThan('today')
image / mimes:pdfFile(mimeTypes:)
prohibited_if:x,1callbackrègles conditionnelles natives
Messages, attributs, préparation
class StoreArticleRequest extends FormRequest
{
    public function rules(): array { … }

    // Messages personnalisés
    public function messages(): array
    {
        return [
            'body.min' => 'Le contenu doit faire au moins 50 caractères.',
        ];
    }

    // Renommer les champs dans les messages par défaut
    public function attributes(): array
    {
        return ['body' => 'contenu de l\'article'];
    }

    // Transformer les données AVANT validation
    protected function prepareForValidation(): void
    {
        $this->merge(['slug' => Str::slug($this->title)]);
    }

    // Validation transverse, APRÈS les règles
    public function after(): array
    {
        return [
            function (Validator $validator) {
                if ($this->published_at && ! $this->body) {
                    $validator->errors()->add('body', 'Un article publié doit avoir un contenu.');
                }
            },
        ];
    }
}
À retenirDans Blade, les erreurs sont disponibles sans rien faire : @error('title') et old('title') repeuplent le formulaire. C'est l'équivalent du rendu de formulaire Symfony, en manuel.
11

Authentification & autorisation

Policies au lieu de Voters

L'authentification est plus rapide à mettre en place qu'en Symfony : un starter kit installe login, inscription, réinitialisation et vérification d'e-mail en une commande. Les autorisations reposent sur les Policies, qui sont conceptuellement vos Voters.

PaquetÀ utiliser quandÉquivalent Symfony
Breezeauth minimaliste, code lisible à personnalisermake:auth + make:registration-form
Jetstreaméquipes, 2FA, sessions, tokensSecurityBundle + bundles tiers
Fortifybackend d'auth sans vues imposéesFortify ≈ Guard sans templates
SanctumSPA même domaine ou tokens d'API simplessession partagée ou JWT léger
Passportserveur OAuth2 completleague/oauth2-server-bundle
SocialiteGoogle, GitHub, LinkedInKnpUOAuth2ClientBundle
composer require laravel/breeze --dev && php artisan breeze:installScaffolding complet en une commande.
php artisan install:apiCrée routes/api.php et installe Sanctum.
php artisan make:policy ArticlePolicy --model=Articlemake:voter

Policies : vos Voters, en plus conventionnel

Même rôle, mais Laravel fait la découverte automatiquement : ArticlePolicy est associée à Article par convention de nommage, et chaque méthode correspond à une action. Pas de supports() à écrire.

Symfony · Voter
class ArticleVoter extends Voter
{
    protected function supports(string $attribute, mixed $subject): bool
    {
        return in_array($attribute, ['EDIT', 'DELETE'])
            && $subject instanceof Article;
    }

    protected function voteOnAttribute($attribute, $subject, $token): bool
    {
        $user = $token->getUser();

        return match ($attribute) {
            'EDIT'   => $subject->getAuthor() === $user,
            'DELETE' => $subject->getAuthor() === $user
                        || in_array('ROLE_ADMIN', $user->getRoles()),
        };
    }
}

// Contrôleur
$this->denyAccessUnlessGranted('EDIT', $article);
Laravel · Policy
class ArticlePolicy
{
    // Court-circuite tout le reste
    public function before(User $user): ?bool
    {
        return $user->isAdmin() ? true : null;
    }

    public function update(User $user, Article $article): bool
    {
        return $user->id === $article->user_id;
    }

    public function delete(User $user, Article $article): bool
    {
        return $user->id === $article->user_id;
    }
}

// Contrôleur
$this->authorize('update', $article);
Les quatre points d'appel
// 1. Contrôleur
$this->authorize('update', $article);          // lève 403
$request->user()->can('update', $article);     // booléen

// 2. Route
Route::put('/articles/{article}', …)->middleware('can:update,article');

// 3. Blade
@can('update', $article) … @endcan
@cannot('delete', $article) … @endcannot

// 4. Automatique sur tout un contrôleur de ressource
public function __construct()
{
    $this->authorizeResource(Article::class, 'article');
}

// Gate simple, sans modèle — l'équivalent d'un access_control
Gate::define('access-admin', fn (User $u) => $u->role === 'admin');

Rôles

À retenirLaravel n'a pas de système de rôles hiérarchique équivalent à role_hierarchy. Il n'y a pas de ROLE_ADMIN qui hérite de ROLE_USER. Vous gérez cela vous-même — une colonne role, un enum, ou le paquet spatie/laravel-permission qui est le standard de fait pour les permissions granulaires.
Authentification programmatique
use Illuminate\Support\Facades\Auth;

Auth::attempt(['email' => $e, 'password' => $p], $remember);
Auth::login($user);
Auth::logout();
Auth::check();      Auth::id();      Auth::user();
auth()->user();     $request->user();

// Tokens Sanctum pour une API
$token = $user->createToken('mobile', ['articles:read'])->plainTextToken;
$user->tokens()->delete();

Route::middleware('auth:sanctum')->group(function () { … });
12

API & ressources JSON

Le Serializer, en plus manuel

Pas de composant Serializer avec des groupes déclaratifs. À la place, une classe Resource qui décrit explicitement la forme JSON de sortie. C'est plus verbeux qu'un #[Groups], mais totalement lisible : le contrat d'API est un fichier PHP que vous relisez.

app/Http/Resources/ArticleResource.php
class ArticleResource extends JsonResource
{
    public function toArray(Request $request): array
    {
        return [
            'id'         => $this->id,
            'title'      => $this->title,
            'slug'       => $this->slug,
            'excerpt'    => $this->excerpt,
            'published'  => (bool) $this->published_at,
            'publishedAt'=> $this->published_at?->toIso8601String(),

            // Inclus seulement si la relation a été chargée — évite le N+1
            'author'     => UserResource::make($this->whenLoaded('author')),
            'tags'       => TagResource::collection($this->whenLoaded('tags')),

            // Conditionnel
            'views'      => $this->when($request->user()?->isAdmin(), $this->views),

            'links'      => [
                'self' => route('api.articles.show', $this->id),
            ],
        ];
    }
}
Utilisation
// Un modèle
return ArticleResource::make($article);

// Une collection, avec pagination automatiquement incluse
return ArticleResource::collection(
    Article::with('author')->paginate(15)
);

// Ajouter des métadonnées
return ArticleResource::collection($articles)->additional([
    'meta' => ['version' => 'v1'],
]);

Contrôleur d'API

routes/api.php — préfixé /api automatiquement
Route::middleware('auth:sanctum')->group(function () {
    Route::apiResource('articles', Api\ArticleController::class);
});

// Limitation de débit
Route::middleware('throttle:60,1')->group(function () { … });
Le contrôleur
class ArticleController extends Controller
{
    public function index(Request $request)
    {
        return ArticleResource::collection(
            Article::with('author')
                ->when($request->query('status'), fn ($q, $s) => $q->where('status', $s))
                ->latest()
                ->paginate($request->integer('per_page', 15))
        );
    }

    public function store(StoreArticleRequest $request)
    {
        $article = Article::create($request->validated());

        return ArticleResource::make($article)
            ->response()
            ->setStatusCode(201);
    }

    public function destroy(Article $article)
    {
        $this->authorize('delete', $article);
        $article->delete();

        return response()->noContent();      // 204
    }
}

Client HTTP sortant

L'équivalent de HttpClientInterface, en façade. L'API est plus concise, les capacités sont comparables.

Illuminate\Support\Facades\Http
$response = Http::withToken($token)
    ->timeout(10)
    ->retry(3, 200)
    ->acceptJson()
    ->post('https://api.exemple.fr/v1/items', ['name' => 'X']);

$response->successful();       // 2xx
$response->status();
$response->json('data.0.id');  // notation par points
$response->throw();            // lève une exception sur 4xx/5xx

// Requêtes concurrentes
$responses = Http::pool(fn ($pool) => [
    $pool->get('https://api.exemple.fr/a'),
    $pool->get('https://api.exemple.fr/b'),
]);

// En test
Http::fake(['api.exemple.fr/*' => Http::response(['ok' => true], 200)]);
À retenirIl n'y a pas d'équivalent Laravel à API Platform dans le cœur. Pour du CRUD REST généré depuis les modèles, l'écosystème propose spatie/laravel-query-builder (filtres, tris, includes conformes à JSON:API) ou laravel/orion. Sinon, on écrit les contrôleurs — c'est la pratique dominante.
13

Asynchrone & planification

Queues = Messenger, Scheduler = cron applicatif

Correspondance quasi terme à terme avec Messenger. Un Job est votre message et votre handler réunis dans une seule classe — Laravel ne sépare pas les deux. Le queue:work est votre messenger:consume.

Symfony · Messenger
// Message
final readonly class SendInvoiceEmail
{
    public function __construct(public int $invoiceId) {}
}

// Handler séparé
#[AsMessageHandler]
final class SendInvoiceEmailHandler
{
    public function __invoke(SendInvoiceEmail $m): void { … }
}

// Envoi
$bus->dispatch(new SendInvoiceEmail($id));

// Worker
// php bin/console messenger:consume async
Laravel · Job
// Message ET handler dans une seule classe
class SendInvoiceEmail implements ShouldQueue
{
    use Queueable;

    public int $tries = 3;
    public int $timeout = 120;

    public function __construct(public Invoice $invoice) {}

    public function handle(Mailer $mailer): void { … }

    public function failed(\Throwable $e): void { … }
}

// Envoi
SendInvoiceEmail::dispatch($invoice);

// Worker
// php artisan queue:work
À retenirNotez le public Invoice $invoice : Laravel sérialise le modèle par son identifiant grâce au trait SerializesModels, et le recharge frais au moment du traitement. Vous passez l'objet, pas l'id — mais c'est bien l'id qui transite.
Dispatcher un job
SendInvoiceEmail::dispatch($invoice);
SendInvoiceEmail::dispatch($invoice)->delay(now()->addMinutes(10));
SendInvoiceEmail::dispatch($invoice)->onQueue('emails');
SendInvoiceEmail::dispatchIf($condition, $invoice);
SendInvoiceEmail::dispatchAfterResponse($invoice);   // ≈ kernel.terminate

// Chaîne séquentielle : le suivant démarre si le précédent réussit
Bus::chain([new ProcessPayment($o), new SendReceipt($o)])->dispatch();

// Lot parallèle avec callback de fin
Bus::batch($jobs)->then(fn () => …)->dispatch();
php artisan make:job SendInvoiceEmailmake:message + make:message-handler
php artisan queue:table && php artisan migrateTable des jobs si driver database.
php artisan queue:work --tries=3 --timeout=90 --max-time=3600Worker de production. À superviser.
php artisan queue:listenEn dev : recharge le code à chaque job, plus lent mais pratique.
php artisan queue:restartObligatoire après chaque déploiement — exactement comme messenger:stop-workers.
php artisan queue:failedmessenger:failed:show
php artisan queue:retry allmessenger:failed:retry

Planification

Plus simple que le Scheduler de Symfony : les tâches se déclarent dans routes/console.php et une seule ligne de cron suffit. Laravel se charge de déterminer ce qui doit tourner à chaque minute.

routes/console.php
use Illuminate\Support\Facades\Schedule;

Schedule::command('sync:invoices')->dailyAt('03:00');
Schedule::job(new CleanTempFiles)->hourly();
Schedule::call(fn () => Cache::forget('stats'))->everyFiveMinutes();

// Garde-fous
Schedule::command('report:heavy')
    ->dailyAt('02:00')
    ->withoutOverlapping()          // pas deux exécutions simultanées
    ->onOneServer()                 // une seule machine du cluster
    ->runInBackground()
    ->emailOutputOnFailure('ops@exemple.fr');
php artisan schedule:listToutes les tâches et leur prochaine exécution.
php artisan schedule:workÉquivalent local, en avant-plan.
* * * * * cd /chemin && php artisan schedule:run >> /dev/null 2>&1La seule ligne de crontab à poser sur le serveur.

Événements

Plus léger que l'EventDispatcher Symfony
// Événement : un simple porteur de données
class ArticlePublished
{
    use Dispatchable, SerializesModels;

    public function __construct(public Article $article) {}
}

// Listener — découvert automatiquement par le type-hint
class NotifySubscribers
{
    public function handle(ArticlePublished $event): void { … }
}

// Listener asynchrone : implémenter ShouldQueue suffit
class NotifySubscribers implements ShouldQueue { … }

// Déclenchement
ArticlePublished::dispatch($article);
event(new ArticlePublished($article));

Observers : les hooks Doctrine

≈ #[AsEntityListener]
// php artisan make:observer ArticleObserver --model=Article
class ArticleObserver
{
    public function creating(Article $article): void
    {
        $article->slug ??= Str::slug($article->title);
    }

    public function updated(Article $article): void { … }
    public function deleted(Article $article): void { … }
}

// AppServiceProvider::boot() — ou l'attribut #[ObservedBy] sur le modèle
Article::observe(ArticleObserver::class);
14

Tests

Pest ou PHPUnit, les deux via artisan test

Vous retrouvez WebTestCase sous un autre nom. Les nouveaux projets utilisent Pest par défaut — syntaxe fonctionnelle, sortie plus lisible — mais PHPUnit reste pleinement supporté et vous pouvez écrire les deux dans le même projet.

Symfony · WebTestCase
class ArticleControllerTest extends WebTestCase
{
    public function testIndex(): void
    {
        $client = static::createClient();
        $crawler = $client->request('GET', '/articles');

        $this->assertResponseIsSuccessful();
        $this->assertSelectorTextContains('h1', 'Articles');
    }

    public function testCreationInterdite(): void
    {
        $client = static::createClient();
        $client->request('GET', '/articles/new');

        $this->assertResponseRedirects('/login');
    }
}
Laravel · Pest
uses(RefreshDatabase::class);

it('affiche la liste des articles', function () {
    Article::factory(3)->create();

    $this->get('/articles')
        ->assertOk()
        ->assertSee('Articles');
});

it('interdit la création aux anonymes', function () {
    $this->get('/articles/create')
        ->assertRedirect('/login');
});

it('enregistre un article valide', function () {
    $this->actingAs(User::factory()->create())
        ->post('/articles', ['title' => 'Bonjour', 'body' => str_repeat('a', 60)])
        ->assertRedirect();

    $this->assertDatabaseHas('articles', ['title' => 'Bonjour']);
});
SymfonyLaravel
assertResponseIsSuccessful()assertOk() / assertSuccessful()
assertResponseStatusCodeSame(422)assertStatus(422)
assertResponseRedirects('/x')assertRedirect('/x')
assertSelectorTextContains()assertSee()
$client->loginUser($user)$this->actingAs($user)
assertEmailCount(1)Mail::assertSent()
assertQueuedMessageCount()Queue::assertPushed()
dama/doctrine-test-bundletrait RefreshDatabase
fixturesfactories
Assertions et doublures fréquentes
// Réponses
$response->assertJson(['ok' => true]);
$response->assertJsonCount(3, 'data');
$response->assertJsonStructure(['data' => [['id', 'title']]]);
$response->assertSessionHasErrors('title');

// Base de données
$this->assertDatabaseHas('articles', ['slug' => 'bonjour']);
$this->assertDatabaseMissing('articles', ['id' => 99]);
$this->assertDatabaseCount('articles', 3);
$this->assertSoftDeleted($article);

// Authentification
$this->assertAuthenticated();
$this->assertGuest();

// Doublures — désactivent réellement le service
Mail::fake();       Mail::assertSent(OrderConfirmation::class);
Queue::fake();      Queue::assertPushed(SendInvoiceEmail::class, 2);
Event::fake();      Event::assertDispatched(ArticlePublished::class);
Storage::fake('public');
Http::fake(['api.exemple.fr/*' => Http::response(['ok' => true])]);
Notification::fake();
php artisan testLance toute la suite avec une sortie lisible.
php artisan test --filter=ArticleTestUn fichier ou une méthode.
php artisan test --parallelNettement plus rapide sur une grosse suite.
php artisan test --coverage --min=80Couverture avec seuil d'échec.
À retenirDans phpunit.xml, réglez DB_CONNECTION=sqlite et DB_DATABASE=:memory:. Avec le trait RefreshDatabase, chaque test tourne dans une transaction annulée — l'équivalent exact de dama/doctrine-test-bundle, mais fourni d'origine.
15

Déploiement

Sur votre VPS, à côté de vos apps Symfony

Rien de dépaysant : même racine public/, même logique de cache à préchauffer, mêmes droits sur le dossier d'écriture. Les noms changent, la séquence est la même.

Séquence de déploiement
php artisan down --secret="maintenance-2026"
git pull origin main

composer install --no-dev --optimize-autoloader
npm ci && npm run build

php artisan migrate --force
php artisan optimize            # config + routes + vues + événements
php artisan storage:link
php artisan queue:restart       # les workers gardent l'ancien code
php artisan up
ÉtapeSymfonyLaravel
Dépendancescomposer install --no-dev -oidentique
Variables figéescomposer dump-env prodphp artisan config:cache
Cache préchauffécache:warmupphp artisan optimize
Migrationsdoctrine:migrations:migrate -nmigrate --force
Assetsasset-map:compilenpm run build
Workersmessenger:stop-workersqueue:restart
Dossier d'écriturevar/storage/ et bootstrap/cache/
Point de contrôleAttendu
Racine du vhost/chemin/public, jamais la racine du projet
APP_DEBUGfalse — sinon vos stack traces sont publiques
APP_ENVproduction
Droitsstorage/ et bootstrap/cache/ inscriptibles par le user PHP-FPM
OPcacheactivé, validate_timestamps=0
Workerssupervisés par Supervisor ou systemd, jamais en nohup
Cronune seule ligne : schedule:run chaque minute
php artisan optimizeMet en cache config, routes, vues et événements.
php artisan optimize:clearL'inverse. Premier réflexe avant tout débogage.
php artisan down --refresh=15 --retry=60Mode maintenance avec en-têtes corrects.
php artisan pailLogs en direct, filtrables. Remplace avantageusement tail -f.
Supervisor pour les workers — /etc/supervisor/conf.d/worker.conf
[program:laravel-worker]
process_name=%(program_name)s_%(process_num)02d
command=php /var/www/monapp/artisan queue:work --sleep=3 --tries=3 --max-time=3600
autostart=true
autorestart=true
user=www-data
numprocs=2
redirect_stderr=true
stdout_logfile=/var/www/monapp/storage/logs/worker.log
stopwaitsecs=3600
16

Les 14 pièges

Ce qui fait perdre du temps quand on vient de Symfony

Cette liste condense les erreurs les plus fréquentes chez les développeurs Doctrine/Symfony qui découvrent Laravel. Relisez-la après une semaine de pratique : la moitié vous sera déjà arrivée.

  1. 01Oublier $fillable. Un champ absent est ignoré silencieusement par create() et update(). Aucune erreur, la colonne reste vide.
  2. 02Croire que save() est différé. Il n'y a pas de flush(). Chaque écriture part immédiatement. Pour un groupe atomique, DB::transaction() est obligatoire.
  3. 03Laisser passer un N+1. Sans with(), chaque accès à une relation déclenche une requête. Activez Model::preventLazyLoading() en local dès le premier jour.
  4. 04Chercher un composant Form. Il n'existe pas. Le HTML s'écrit à la main, la validation vit dans une Form Request. C'est normal, ce n'est pas vous qui n'avez pas trouvé.
  5. 05Attendre une migration générée par diff. make:migration crée un fichier vide. La migration est la source de vérité, le modèle ne décrit pas le schéma.
  6. 06Appeler env() hors de config/. Après config:cache, tous les env() hors config renvoient null. En production, l'application casse sans message clair.
  7. 07Oublier queue:restart au déploiement. Les workers gardent l'ancien code en mémoire, exactement comme messenger:stop-workers.
  8. 08Mettre des routes en closure puis lancer route:cache. Les closures ne sont pas sérialisables : la commande échoue. Utilisez toujours [Controller::class, 'method'].
  9. 09Confondre $guarded = [] et « pas de protection ». C'est exactement ça : tout devient assignable en masse. Acceptable sur un projet interne, dangereux sur un formulaire public.
  10. 10Chercher un debug:container. Il n'y a pas d'équivalent riche. php artisan about et la lecture des ServiceProviders remplacent l'introspection Symfony.
  11. 11Ignorer les collections. ->get() renvoie une Collection, pas un tableau. Elle offre map, filter, groupBy, sortBy, pluck — c'est un gain de lisibilité énorme, et beaucoup y viennent trop tard.
  12. 12Écrire des requêtes dans le contrôleur. Rien ne l'interdit, et c'est précisément le risque. Sortez-les en scopes sur le modèle, comme vous sortiez vos DQL en repository.
  13. 13Négliger les rôles. Pas de role_hierarchy. Décidez tôt de votre modèle de permissions, ou installez spatie/laravel-permission.
  14. 14Ne pas utiliser tinker. C'est l'outil qui vous fera progresser le plus vite sur Eloquent. Ouvrez-le à chaque question au lieu d'écrire un test jetable.
Ce que vous garderez de SymfonyVotre réflexe d'injection par constructeur, votre discipline sur la séparation des couches, votre habitude de tester, votre connaissance de SQL et des index. Rien de tout cela ne devient inutile — Laravel permet simplement d'aller vite sans cette rigueur, ce qui rend celle-ci d'autant plus différenciante sur un projet réel.