A deep-dive guide for Symfony developers migrating to Laravel. Includes side-by-side comparisons and idiomatic code.
Concept | Symfony | Laravel |
---|---|---|
Framework style | Components-first, explicit | Batteries-included, expressive |
Config | YAML/PHP/XML + .env | PHP arrays in config/*.php + .env |
DI Container | Autowiring via Symfony DI | Autowiring via Container; Service Providers |
ORM | Doctrine (Entities+Repositories) | Eloquent (Active Record) |
Routing | Attributes/YAML | Routes in routes/*.php , attributes supported |
Controllers | Services with attributes | Controllers (resource/invokable); route model binding |
Templates | Twig | Blade |
Validation | Validator constraints | Form Requests / inline rules |
Security/Auth | Security component, voters | Guards, middleware, Gates/Policies |
APIs | API Platform, Serializer | API Resources, Sanctum/Passport |
Background | Messenger | Queues, Jobs, Events/Listeners |
Scheduling | Cron + Scheduler | Scheduler in Console/Kernel.php |
Storage | Flysystem, Filesystem | Storage facade (Flysystem) |
Caching | Cache pools | Cache facade, tags |
Admin | Sonata/EasyAdmin | Filament/Nova/Backpack/Orchid |
Frontend | UX, Encore/Vite | Vite, Livewire/Alpine, Inertia |
Testing | PHPUnit/Pest | Pest/PHPUnit, Dusk |
bin/console
, config/
, src/
, templates/
, migrations/
, public/
artisan
, app/
, routes/
, resources/views
, database/migrations|seeders|factories
, public/
// src/Entity/Post.php
#[ORM\Entity(repositoryClass: PostRepository::class)]
class Post {
#[ORM/Id, ORM/GeneratedValue, ORM/Column] private ?int $id = null;
#[ORM/Column(length: 255)] private string $title;
#[ORM/Column(type: 'text')] private string $body;
#[ORM/OneToMany(mappedBy: 'post', targetEntity: Comment::class, orphanRemoval: true)]
private Collection $comments;
}
// src/Repository/PostRepository.php
class PostRepository extends ServiceEntityRepository {
public function findPublished(): array {
return $this->createQueryBuilder('p')
->andWhere('p.publishedAt IS NOT NULL')
->orderBy('p.publishedAt', 'DESC')
->getQuery()->getResult();
}
}
// app/Models/Post.php
class Post extends Model
{
protected $fillable = ['title','body','published_at'];
protected $casts = ['published_at' => 'datetime'];
public function comments() { return $this->hasMany(Comment::class); }
}
// database/migrations/2025_01_01_create_posts.php
Schema::create('posts', function (Blueprint $table) {
$table->id();
$table->string('title');
$table->text('body');
$table->timestamp('published_at')->nullable();
$table->timestamps();
});
// Optional repository pattern
class PostRepository {
public function findPublished() {
return Post::whereNotNull('published_at')->latest('published_at')->get();
}
}
// app/Models/Post.php
public function scopePublished($q) { return $q->whereNotNull('published_at'); }
// Usage: Post::published()->latest('published_at')->get();
#[Route('/posts', name: 'post_index', methods: ['GET'])]
public function index(PostRepository $repo): Response {
return $this->render('post/index.html.twig', ['posts' => $repo->findAll()]);
}
// routes/web.php
Route::get('/posts', [PostController::class, 'index'])->name('post.index');
// app/Http/Controllers/PostController.php
class PostController extends Controller {
public function index() {
$posts = Post::all();
return view('post.index', compact('posts'));
}
}
Extras: Resource controllers (php artisan make:controller PostController --resource
) and implicit route model binding (public function show(Post $post)
).
$violations = $validator->validate($dto, new Assert\Collection([...]));
// app/Http/Requests/StorePostRequest.php
class StorePostRequest extends FormRequest {
public function rules() {
return ['title' => ['required','string','max:255'], 'body' => ['required','string']];
}
}
// Controller
public function store(StorePostRequest $request) {
Post::create($request->validated());
}
// app/Policies/PostPolicy.php
class PostPolicy {
public function update(User $user, Post $post) {
return $user->id === $post->user_id || $user->is_admin;
}
}
// AuthServiceProvider::$policies = [Post::class => PostPolicy::class];
// Usage: $this->authorize('update', $post); or @can('update', $post)
API auth: Use Sanctum for SPA/token auth; Passport for OAuth2.
// app/Http/Resources/PostResource.php
class PostResource extends JsonResource {
public function toArray($request) {
return [
'id' => $this->id,
'title' => $this->title,
'body' => $this->when(!$request->boolean('summary'), $this->body),
'published_at' => optional($this->published_at)->toIso8601String(),
'author' => new UserResource($this->whenLoaded('author')),
];
}
}
// routes/api.php
Route::get('/posts', fn() => PostResource::collection(Post::with('author')->paginate()));
Generate OpenAPI docs via community packages (e.g., l5-swagger).
// Job
class SendNewsletter implements ShouldQueue {
public function handle() { /* ... */ }
}
// Dispatch
dispatch(new SendNewsletter());
// Schedule in app/Console/Kernel.php
protected function schedule(Schedule $schedule) {
$schedule->job(new SendNewsletter)->dailyAt('08:00');
}
// Mailable
Mail::to($user)->send(new WelcomeMail($user));
// Notification
$user->notify(new InvoicePaid($invoice));
// Storage (Flysystem)
Storage::disk('s3')->put('reports/monthly.pdf', $pdfBytes);
$url = Storage::disk('s3')->temporaryUrl('reports/monthly.pdf', now()->addHours(2));
Recommended: Filament (free, excellent DX). Others: Nova (official, paid), Backpack, Orchid.
// Filament Post resource (snippet)
class PostResource extends Resource
{
protected static ?string $model = Post::class;
public static function form(Form $form): Form {
return $form->schema([
TextInput::make('title')->required()->maxLength(255),
RichEditor::make('body')->required(),
Toggle::make('published')->default(false),
]);
}
public static function table(Table $table): Table {
return $table->columns([
TextColumn::make('title')->searchable()->sortable(),
IconColumn::make('published')->boolean(),
TextColumn::make('published_at')->dateTime()->sortable(),
])->filters([
TernaryFilter::make('published'),
]);
}
}
$this->actingAs(User::factory()->create())
->post('/posts', ['title'=>'T','body'=>'...'])
->assertRedirect()
->assertSessionHasNoErrors();
Use RefreshDatabase
and factories for fast, isolated tests. Dusk for browser automation.
// Comments in a controller
public function storeComment(StoreCommentRequest $request, Post $post) {
$this->authorize('comment', $post);
$post->comments()->create([
'body' => $request->validated('body'),
'author_id' => $request->user()->id,
]);
return back();
}
// app/Providers/AppServiceProvider.php
public function register() {
$this->app->bind(PaymentGateway::class, function($app){
return new StripeGateway(config('services.stripe.key'));
});
}
Use interface bindings for swap-ability; move env-backed options to config/*.php
.
// app/Http/Middleware/EnsureIsAdmin.php
public function handle($request, Closure $next) {
abort_unless($request->user()?->is_admin, 403);
return $next($request);
}
// Register in Kernel or on routes
->with()
for eager loading.Model::create()
or $model->save()
; no Unit of Work.Cache::remember()
is idiomatic for denormalized reads.php artisan queue:work
.$posts = Cache::remember('posts:home', 600, fn() => Post::published()->limit(10)->get());
php artisan make:model Post -mcr
a few times—it mirrors your Symfony muscle memory, faster.
bin/console
→ php artisan
auth
, verified
)@csrf
Blade directive