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.
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
| Sujet | Symfony | Laravel | Conséquence pratique |
|---|---|---|---|
| Persistance | Data Mapper (Doctrine) | Active Record (Eloquent) | Plus d'EntityManager, plus de flush(). $model->save() écrit tout de suite. |
| Dépendances | Injection par constructeur | Injection ou façades | Cache::get() marche sans rien injecter. C'est un service résolu à l'exécution. |
| Configuration | Explicite dans config/ | Convention, config à la demande | Une table s'appelle articles pour Article sans rien écrire. |
| Routing | Attributs sur le contrôleur | Fichier central routes/web.php | La 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.
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');
}public function store(Request $request)
{
$article = Article::create([
'title' => $request->input('title'),
'slug' => Str::slug($request->input('title')),
]);
return to_route('articles.index');
}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.
// 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.
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.
| Étape | Symfony | Laravel |
|---|---|---|
| Point d'entrée | public/index.php | public/index.php |
| Amorçage | src/Kernel.php | bootstrap/app.php |
| Chaîne d'interception | EventListener sur kernel.request | Middleware (pile, ordre explicite) |
| Résolution de la route | Router + attributs | Router + routes/web.php |
| Injection du contrôleur | ArgumentResolver | Service container + Route Model Binding |
| Réponse | Objet Response | Objet Response |
| Après réponse | kernel.terminate | Middleware 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.
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 Symfony | Remarque |
|---|---|---|
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/ | EventSubscriber | Pile ordonnée, pas de priorités numériques |
app/Providers/ | services.yaml + bundles | Le 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 route | Fichiers centraux |
config/ | config/packages/ | Fichiers PHP, publiés à la demande |
storage/ | var/ | Logs, cache, uploads |
config/bundles.php, et c'est normal.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.
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
| Symfony | Laravel |
|---|---|
bin/console cache:clear | artisan optimize:clear |
bin/console debug:router | artisan route:list |
bin/console make:entity | artisan make:model -m |
bin/console make:crud | artisan make:model --all |
bin/console make:migration | artisan make:migration (vide, à écrire) |
doctrine:migrations:migrate | artisan migrate |
doctrine:fixtures:load | artisan db:seed |
messenger:consume async | artisan queue:work |
bin/console make:command | artisan make:command |
debug:autowiring | pas d'équivalent |
debug:container | pas d'équivalent |
| pas d'équivalent | artisan tinker |
Les générateurs
| Commande | Génère |
|---|---|
make:model Article | le modèle seul |
make:model Article -mfsc | modèle + migration + factory + seeder + contrôleur |
make:model Article --all | tout, y compris policy et form requests |
make:controller X --resource | les 7 méthodes CRUD |
make:controller X --api | CRUD sans create ni edit |
make:request StoreArticleRequest | validation dédiée |
make:resource ArticleResource | transformateur JSON |
make:policy ArticlePolicy --model=Article | autorisations |
make:job · make:event · make:listener | asynchrone et événements |
make:observer X --model=Article | hooks du cycle de vie du modèle |
make:test ArticleTest | test de fonctionnalité |
-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.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.
#[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 {}
}// 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.
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 & URI | Méthode | Nom généré |
|---|---|---|
| GET /articles | index | articles.index |
| GET /articles/create | create | articles.create |
| POST /articles | store | articles.store |
| GET /articles/{article} | show | articles.show |
| GET /articles/{article}/edit | edit | articles.edit |
| PUT/PATCH /articles/{article} | update | articles.update |
| DELETE /articles/{article} | destroy | articles.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.
// 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.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) }}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.
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');
}
}| Symfony | Laravel |
|---|---|
$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
$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();$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.
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);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.
{% 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 %}@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
@endsectionCorrespondance des directives
| Twig | Blade |
|---|---|
{% 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.user | auth()->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.
@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>×</button>
@endif
</div><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
<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>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.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.
#[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();
}
}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ément | Convention | Pour surcharger |
|---|---|---|
| Table | pluriel snake_case du modèle : Article → articles | protected $table = 'art'; |
| Clé primaire | id, entier auto-incrémenté | protected $primaryKey = 'uuid'; |
| Timestamps | created_at et updated_at gérés seuls | public $timestamps = false; |
| Clé étrangère | user_id pour une relation vers User | 2ᵉ argument de belongsTo() |
| Table pivot | les deux noms au singulier, ordre alphabétique : article_tag | 2ᵉ argument de belongsToMany() |
$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
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 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();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.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.
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) { … }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.
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);
}
}| Doctrine | Eloquent |
|---|---|
#[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 map | relations 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.
// ❌ 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 absentAppServiceProvider::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
// 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
// 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
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.
Migrations, factories, seeders
Écrites à la main, pas générées par diff
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.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');
}
};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 migrate≈ doctrine:migrations:migratephp artisan migrate --pretendAffiche le SQL sans l'exécuter.php artisan migrate:status≈ doctrine:migrations:statusphp 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.
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));
}
}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();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:seed≈ doctrine: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.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.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.
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()) { … }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
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 Symfony | Note |
|---|---|---|
required | NotBlank | |
nullable | champ nullable | sans ça, null échoue sur les autres règles |
sometimes | groupes de validation | valider seulement si le champ est présent |
email:rfc,dns | Email(mode: 'strict') | |
min:3 / max:255 | Length | sur un nombre, borne la valeur ; sur un fichier, la taille |
confirmed | RepeatedType | exige un champ xxx_confirmation |
unique:users,email | UniqueEntity | |
exists:tags,id | pas d'équivalent direct | vérifie la présence en base |
in:draft,published | Choice | |
Rule::enum(Status::class) | Choice(callback:) | |
after:today | GreaterThan('today') | |
image / mimes:pdf | File(mimeTypes:) | |
prohibited_if:x,1 | callback | règles conditionnelles natives |
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.');
}
},
];
}
}@error('title') et old('title') repeuplent le formulaire. C'est l'équivalent du rendu de formulaire Symfony, en manuel.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 |
|---|---|---|
| Breeze | auth minimaliste, code lisible à personnaliser | make:auth + make:registration-form |
| Jetstream | équipes, 2FA, sessions, tokens | SecurityBundle + bundles tiers |
| Fortify | backend d'auth sans vues imposées | Fortify ≈ Guard sans templates |
| Sanctum | SPA même domaine ou tokens d'API simples | session partagée ou JWT léger |
| Passport | serveur OAuth2 complet | league/oauth2-server-bundle |
| Socialite | Google, GitHub, LinkedIn | KnpUOAuth2ClientBundle |
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=Article≈ make:voterPolicies : 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.
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);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);// 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
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.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 () { … });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.
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),
],
];
}
}// 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
Route::middleware('auth:sanctum')->group(function () {
Route::apiResource('articles', Api\ArticleController::class);
});
// Limitation de débit
Route::middleware('throttle:60,1')->group(function () { … });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.
$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)]);spatie/laravel-query-builder (filtres, tris, includes conformes à JSON:API) ou laravel/orion. Sinon, on écrit les contrôleurs — c'est la pratique dominante.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.
// 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// 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:workpublic 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.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 SendInvoiceEmail≈ make:message + make:message-handlerphp 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:failed≈ messenger:failed:showphp artisan queue:retry all≈ messenger:failed:retryPlanification
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.
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
// É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
// 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);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.
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');
}
}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']);
});| Symfony | Laravel |
|---|---|
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-bundle | trait RefreshDatabase |
| fixtures | factories |
// 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.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.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.
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| Étape | Symfony | Laravel |
|---|---|---|
| Dépendances | composer install --no-dev -o | identique |
| Variables figées | composer dump-env prod | php artisan config:cache |
| Cache préchauffé | cache:warmup | php artisan optimize |
| Migrations | doctrine:migrations:migrate -n | migrate --force |
| Assets | asset-map:compile | npm run build |
| Workers | messenger:stop-workers | queue:restart |
| Dossier d'écriture | var/ | storage/ et bootstrap/cache/ |
| Point de contrôle | Attendu |
|---|---|
| Racine du vhost | /chemin/public, jamais la racine du projet |
APP_DEBUG | false — sinon vos stack traces sont publiques |
APP_ENV | production |
| Droits | storage/ et bootstrap/cache/ inscriptibles par le user PHP-FPM |
| OPcache | activé, validate_timestamps=0 |
| Workers | supervisés par Supervisor ou systemd, jamais en nohup |
| Cron | une 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.[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=3600Les 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.
- 01Oublier
$fillable. Un champ absent est ignoré silencieusement parcreate()etupdate(). Aucune erreur, la colonne reste vide. - 02Croire que
save()est différé. Il n'y a pas deflush(). Chaque écriture part immédiatement. Pour un groupe atomique,DB::transaction()est obligatoire. - 03Laisser passer un N+1. Sans
with(), chaque accès à une relation déclenche une requête. ActivezModel::preventLazyLoading()en local dès le premier jour. - 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é.
- 05Attendre une migration générée par diff.
make:migrationcrée un fichier vide. La migration est la source de vérité, le modèle ne décrit pas le schéma. - 06Appeler
env()hors deconfig/. Aprèsconfig:cache, tous lesenv()hors config renvoientnull. En production, l'application casse sans message clair. - 07Oublier
queue:restartau déploiement. Les workers gardent l'ancien code en mémoire, exactement commemessenger:stop-workers. - 08Mettre des routes en closure puis lancer
route:cache. Les closures ne sont pas sérialisables : la commande échoue. Utilisez toujours[Controller::class, 'method']. - 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. - 10Chercher un
debug:container. Il n'y a pas d'équivalent riche.php artisan aboutet la lecture des ServiceProviders remplacent l'introspection Symfony. - 11Ignorer les collections.
->get()renvoie uneCollection, pas un tableau. Elle offremap,filter,groupBy,sortBy,pluck— c'est un gain de lisibilité énorme, et beaucoup y viennent trop tard. - 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.
- 13Négliger les rôles. Pas de
role_hierarchy. Décidez tôt de votre modèle de permissions, ou installezspatie/laravel-permission. - 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.