Laravel Explained: Why It's the PHP Framework That Actually Makes Sense
PHP has been powering the web for nearly 30 years, but writing raw PHP for a modern web application is a bit like navigating a city with only a hand-drawn map. You can do it, but you'll waste time, get lost, and end up with something nobody else can read. Laravel changed that conversation. Since its first release in 2011, it has become the most starred PHP framework on GitHub and the default choice for teams who want to ship production-quality applications without starting from scratch every time.
The real problem Laravel solves isn't 'PHP is hard' — it's 'web applications are complex'. Every app needs authentication, database access, input validation, session management, queued jobs, and a hundred other cross-cutting concerns. Without a framework you either bolt these together inconsistently, copy-paste from Stack Overflow, or spend months writing boilerplate. Laravel gives you a coherent, well-documented answer to all of these concerns, following conventions that your whole team can agree on.
By the end of this article you'll understand exactly what Laravel is and why it was built, how its MVC architecture maps to real requests, how to set up a project and write your first route, controller, and Blade view, and — most importantly — when to reach for Laravel's built-in tools instead of rolling your own. You'll also walk away knowing the gotchas that trip up developers who come from plain PHP, and the questions that interviewers love to ask.
What Laravel Actually Is — MVC, the Service Container, and the Request Lifecycle
Laravel is a full-stack PHP framework built on top of Composer packages and underpinned by two big ideas: convention over configuration and inversion of control.
Convention over configuration means Laravel makes sensible decisions for you. Put a model in app/Models, a controller in app/Http/Controllers, and a view in resources/views — the framework finds them automatically. You only configure the things that differ from the default.
Inversion of control is handled by Laravel's Service Container, which is essentially an intelligent object factory. Instead of calling new DatabaseConnection() deep in your code, you ask the container for a DatabaseConnection and it builds one — injecting its own dependencies automatically. This makes testing dramatically easier because you can swap real implementations for fakes.
The request lifecycle ties it together: an HTTP request hits public/index.php, gets wrapped in an Illuminate\Http\Request object, travels through global middleware (think authentication checks, CORS headers), hits the Router which matches the URL to a closure or controller method, passes through route-specific middleware, runs your controller logic, then returns an Illuminate\Http\Response — a Blade view, JSON, a redirect, or a file download. Every single step is predictable and overridable.
<?php // FILE: routes/web.php // This is where you map URLs to behaviour. // Laravel reads this file on every request. use Illuminate\Http\Request; use App\Http\Controllers\ArticleController; // A simple closure route — great for prototyping or tiny endpoints Route::get('/ping', function () { // Returns a JSON response — Laravel wraps the array automatically return response()->json([ 'status' => 'alive', 'version' => app()->version(), // pulls the Laravel version from the container ]); }); // A resourceful route — maps 7 CRUD actions to one controller in one line // GET /articles -> ArticleController@index // GET /articles/{id} -> ArticleController@show // POST /articles -> ArticleController@store // PUT /articles/{id} -> ArticleController@update // DELETE /articles/{id} -> ArticleController@destroy Route::resource('articles', ArticleController::class); // A route with middleware — only authenticated users reach this endpoint Route::middleware('auth')->group(function () { Route::get('/dashboard', function (Request $request) { // $request->user() is resolved by the container via the auth guard $currentUser = $request->user(); return view('dashboard', [ 'userName' => $currentUser->name, 'articleCount' => $currentUser->articles()->count(), ]); }); });
{
"status": "alive",
"version": "10.x"
}
GET /articles → Calls ArticleController@index
GET /dashboard (unauthenticated) → HTTP 302 redirect to /login
GET /dashboard (authenticated) → Renders resources/views/dashboard.blade.php
Eloquent ORM — Talking to Your Database Without Writing Raw SQL
Every web app needs a database. The question is how much of your time you spend fighting SQL strings versus building features. Laravel's Eloquent ORM answers that by treating each database table as a PHP class and each row as an object. You get a fluent, readable API that handles 95% of queries without a single line of SQL.
Eloquent is an ActiveRecord implementation — the model knows about the database and can save itself. An Article model represents the articles table. Call Article::all() and you get a Collection of Article objects. Call $article->save() and the row is written. Relationships are declared as methods (hasMany, belongsTo, belongsToMany) and loaded lazily or eagerly with with().
The piece most beginners miss is that Eloquent returns Illuminate Collections, not plain arrays. Collections come with 80+ higher-order methods — filter, map, groupBy, pluck, sortBy — all chainable, all lazy where possible. Learning Collections is arguably more valuable than memorising query builder syntax.
For schema changes, Eloquent works alongside Migrations. A migration is a version-controlled PHP file that describes a database change. Your whole team runs php artisan migrate and everyone's database matches — no more 'it works on my machine' database drift.
<?php // FILE: app/Models/Article.php // One class = one table. Laravel pluralises 'Article' to 'articles' automatically. namespace App\Models; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\HasMany; class Article extends Model { // Only these fields can be mass-assigned via Article::create([...]) or fill() // This prevents mass-assignment vulnerabilities — always define this protected $fillable = ['title', 'body', 'published_at', 'author_id']; // Cast 'published_at' to a Carbon datetime object automatically // so you can call $article->published_at->diffForHumans() etc. protected $casts = [ 'published_at' => 'datetime', ]; // Relationship: an Article belongs to one Author (User) // Laravel infers the foreign key is 'author_id' by convention public function author(): BelongsTo { return $this->belongsTo(User::class, 'author_id'); } // Relationship: an Article has many Comments public function comments(): HasMany { return $this->hasMany(Comment::class); } // A local scope — reusable query constraint // Usage: Article::published()->get() public function scopePublished($query) { return $query->whereNotNull('published_at') ->where('published_at', '<=', now()); } } // ------------------------------------------------------- // FILE: app/Http/Controllers/ArticleController.php // Real-world usage: eager loading to avoid the N+1 problem namespace App\Http\Controllers; use App\Models\Article; use Illuminate\Http\Request; class ArticleController extends Controller { public function index() { // with('author') eager-loads the author relationship in ONE extra query // instead of firing a new query per article (the N+1 problem) $publishedArticles = Article::published() ->with('author') // eager load author ->withCount('comments') // adds comments_count column to each result ->latest('published_at') // ORDER BY published_at DESC ->paginate(15); // automatically handles ?page= query string return view('articles.index', [ 'articles' => $publishedArticles, ]); } public function store(Request $request) { // Validate first — Laravel throws a 422 with JSON errors if this fails $validatedData = $request->validate([ 'title' => 'required|string|max:200', 'body' => 'required|string', 'published_at' => 'nullable|date', ]); // merge the authenticated user's ID — never trust a user-supplied author_id $article = Article::create(array_merge($validatedData, [ 'author_id' => $request->user()->id, ])); return redirect()->route('articles.show', $article) ->with('success', 'Article published successfully.'); } }
// Eloquent fires exactly 3 queries:
// 1. SELECT COUNT(*) FROM articles WHERE published_at <= NOW() (pagination count)
// 2. SELECT * FROM articles WHERE published_at <= NOW() ORDER BY published_at DESC LIMIT 15
// 3. SELECT * FROM users WHERE id IN (1, 2, 5, 7 ...) (eager load — all authors in ONE query)
//
// After POST /articles with valid data:
// INSERT INTO articles (title, body, author_id, created_at, updated_at) VALUES (...)
// HTTP 302 → /articles/42
// Session flash: 'Article published successfully.'
Blade Templates — Logic-Light Views That Don't Fight You
Blade is Laravel's templating engine and it solves a real frustration: mixing raw PHP into HTML creates spaghetti that nobody wants to maintain. Blade gives you clean, readable directives — @if, @foreach, @auth, @include — that compile to plain PHP and get cached, so there's zero runtime overhead.
The killer feature is template inheritance. You define a master layout (layouts/app.blade.php) that contains your HTML skeleton, navigation, and footer. Every child view @extends that layout and fills in named @section blocks. Change the navigation once in the layout and every page updates. No copy-pasting. No include chains.
Blade also auto-escapes output by default. {{ $userInput }} runs through htmlspecialchars — you get XSS protection for free. The only time you bypass it is when you explicitly use {!! $trustedHtml !!}, which is a deliberate, visible decision rather than an easy accident.
Components (introduced in Laravel 7) take this further — they let you build reusable UI chunks like that compile to a Blade partial with its own logic class. Think of them as PHP-powered web components.
{{-- FILE: resources/views/layouts/app.blade.php --}}
{{-- The master layout — every page on the site extends this --}}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>@yield('page-title', 'TheCodeForge') — My Blog</title>
</head>
<body>
<nav>
{{-- @auth only renders its contents if a user is logged in --}}
@auth
<span>Welcome, {{ auth()->user()->name }}</span>
<a href="{{ route('logout') }}">Logout</a>
@else
<a href="{{ route('login') }}">Login</a>
@endauth
</nav>
<main>
{{-- @yield marks the slot that child views fill in --}}
@yield('content')
</main>
</body>
</html>
{{-- FILE: resources/views/articles/index.blade.php --}}
{{-- Child view — it extends the layout and fills the 'content' slot --}}
@extends('layouts.app')
@section('page-title', 'Latest Articles')
@section('content')
<h1>Published Articles</h1>
{{-- Flash message from the controller's redirect()->with('success', ...) --}}
@if(session('success'))
<div class="alert alert-success">
{{ session('success') }}
</div>
@endif
@forelse($articles as $article)
<article>
<h2>
{{-- route() generates /articles/42 — no hardcoded URLs --}}
<a href="{{ route('articles.show', $article) }}">
{{ $article->title }} {{-- auto-escaped: safe against XSS --}}
</a>
</h2>
<p class="meta">
By {{ $article->author->name }}
·
{{-- Carbon's diffForHumans() gives '3 days ago' style output --}}
{{ $article->published_at->diffForHumans() }}
·
{{ $article->comments_count }} comment{{ $article->comments_count !== 1 ? 's' : '' }}
</p>
<p>{{ Str::limit($article->body, 200) }}</p>
</article>
@empty
{{-- @forelse's @empty block handles the zero-results state cleanly --}}
<p>No articles published yet. <a href="{{ route('articles.create') }}">Write one?</a></p>
@endforelse
{{-- $articles->links() renders Bootstrap/Tailwind pagination automatically --}}
{{ $articles->links() }}
@endsection
<!DOCTYPE html>
<html lang="en">
<head>
<title>Latest Articles — My Blog</title>
</head>
<body>
<nav>
<a href="/login">Login</a> <!-- unauthenticated user -->
</nav>
<main>
<h1>Published Articles</h1>
<article>
<h2><a href="/articles/1">Getting Started with Laravel</a></h2>
<p class="meta">By Jane Doe · 3 days ago · 4 comments</p>
<p>Laravel is a web application framework with expressive, elegant syntax...</p>
</article>
<!-- ... 14 more articles ... -->
<!-- Pagination: « Previous 1 2 3 Next » -->
</main>
</body>
</html>
Artisan CLI — The Command-Line Superpower You'll Use Every Day
Every Laravel project ships with Artisan, a CLI tool that handles the repetitive parts of development so you don't have to. It's not just a code generator — it's your interface to the application itself.
The commands you'll use daily are make:model, make:controller, make:migration, and make:request. The -mrc flag on make:model generates the model, migration, resource controller, and form request all at once — four files with one command. That's a lot of boilerplate gone in seconds.
Beyond generation, Artisan lets you run migrations (migrate, migrate:rollback, migrate:fresh), manage the cache (cache:clear, config:cache, route:cache), tail logs, run scheduled tasks, and drop into a REPL called Tinker. Tinker is a REPL that boots your entire Laravel app — you can query your database, test Eloquent queries, and call any service from the command line without writing a throwaway script.
You can also write your own Artisan commands. Any repetitive task — importing a CSV, recalculating stats, sending a digest email — can become a first-class php artisan command, schedulable via Laravel's Scheduler and monitored via Laravel Horizon or Telescope.
<?php /* |------------------------------------------------------------------ | TERMINAL SESSION — typical Laravel development workflow |------------------------------------------------------------------ */ // 1. Create a new Laravel project // composer create-project laravel/laravel blog-platform // cd blog-platform // 2. Generate the Article model + migration + resource controller + form request in one shot // php artisan make:model Article -mrc --requests // // This creates: // app/Models/Article.php // database/migrations/2024_01_15_create_articles_table.php // app/Http/Controllers/ArticleController.php (with index/create/store/show/edit/update/destroy stubs) // app/Http/Requests/StoreArticleRequest.php // app/Http/Requests/UpdateArticleRequest.php // 3. The generated migration — you fill in the columns: // FILE: database/migrations/2024_01_15_000000_create_articles_table.php use Illuminate\Database\Migrations\Migration; use Illuminate\Database\Schema\Blueprint; use Illuminate\Support\Facades\Schema; return new class extends Migration { public function up(): void { Schema::create('articles', function (Blueprint $table) { $table->id(); // BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY $table->foreignId('author_id') // BIGINT UNSIGNED ->constrained('users') // adds FOREIGN KEY referencing users.id ->cascadeOnDelete(); // delete articles when user is deleted $table->string('title', 200); $table->text('body'); $table->timestamp('published_at')->nullable(); // null = draft $table->timestamps(); // adds created_at and updated_at }); } public function down(): void { // Rollback: drop the table cleanly Schema::dropIfExists('articles'); } }; // 4. The generated Form Request — move validation OUT of the controller: // FILE: app/Http/Requests/StoreArticleRequest.php namespace App\Http\Requests; use Illuminate\Foundation\Http\FormRequest; class StoreArticleRequest extends FormRequest { // authorize() decides WHO can make this request // Return false to send a 403 Forbidden automatically public function authorize(): bool { return $this->user() !== null; // any logged-in user can create articles } // rules() defines validation — same rules as $request->validate() but reusable public function rules(): array { return [ 'title' => ['required', 'string', 'max:200'], 'body' => ['required', 'string', 'min:50'], 'published_at' => ['nullable', 'date', 'after_or_equal:today'], ]; } // Optional: custom error messages public function messages(): array { return [ 'body.min' => 'Articles must be at least 50 characters long — give your readers something!', ]; } }
INFO Model [app/Models/Article.php] created successfully.
INFO Migration [database/migrations/2024_01_15_create_articles_table.php] created successfully.
INFO Request [app/Http/Requests/StoreArticleRequest.php] created successfully.
INFO Request [app/Http/Requests/UpdateArticleRequest.php] created successfully.
INFO Controller [app/Http/Controllers/ArticleController.php] created successfully.
// Terminal output from: php artisan migrate
INFO Running migrations.
2014_10_12_000000_create_users_table ............... 12ms DONE
2024_01_15_000000_create_articles_table ............ 8ms DONE
// Terminal output from: php artisan tinker
>>> Article::published()->withCount('comments')->first()
=> App\Models\Article {#3241
id: 1,
title: "Getting Started with Laravel",
published_at: "2024-01-12 09:00:00",
comments_count: 4,
}
| Feature / Aspect | Raw PHP | Laravel |
|---|---|---|
| Routing | Parse $_SERVER['REQUEST_URI'] manually | Route::get('/path', Handler::class) — named, grouped, middleware-aware |
| Database access | PDO with manual prepared statements | Eloquent ORM + Query Builder — fluent, safe, relationship-aware |
| Input validation | Custom if/else logic per form field | Declarative rules array — auto 422 response with JSON error bag |
| Authentication | Sessions + password_hash() + manual checks | php artisan make:auth or Laravel Breeze/Jetstream — fully featured in minutes |
| Templating | echo + htmlspecialchars() everywhere | Blade — auto-escaping, inheritance, components, zero runtime overhead |
| Database migrations | Manual SQL scripts shared via Slack | Version-controlled PHP migration files run with php artisan migrate |
| Testing | PHPUnit wired up manually | PHPUnit + Pest pre-configured, HTTP test helpers, database factories built-in |
| Caching | Custom Redis/Memcached integration | Unified Cache facade — swap drivers (Redis, file, array) via .env config |
| Queue/Jobs | Cron jobs + custom queue tables | Queue facade — drivers for Redis, SQS, database; monitored by Horizon |
🎯 Key Takeaways
- Laravel's convention-over-configuration approach means your project structure IS your documentation — any Laravel developer can navigate your codebase on day one without a guide.
- Eloquent's $fillable is not optional — it's your first line of defence against mass-assignment attacks. Define it on every model before you call create() or fill().
- Route::resource() maps seven RESTful actions to a controller in one line and enforces URL conventions your whole team shares — use it for any resource that has CRUD operations.
- Artisan's make:model -mrc --requests command is a force multiplier — it generates the model, migration, controller, and form requests simultaneously, eliminating boilerplate so you can focus on business logic immediately.
⚠ Common Mistakes to Avoid
- ✕Mistake 1: Skipping $fillable on Eloquent models — Symptom: MassAssignmentException when calling Article::create($request->all()) — Fix: Always define protected $fillable = ['title', 'body', ...] with the exact columns you want users to be able to set. Never pass $request->all() into create() without either $fillable or explicitly picking fields with $request->only(['title', 'body']).
- ✕Mistake 2: Triggering the N+1 query problem — Symptom: Your article list page fires 51 queries when you have 50 articles, each one fetching the author. The page is visibly slow and Debugbar screams at you — Fix: Add ->with('author') to your Eloquent query in the controller. If you discover it after the fact, install barryvdh/laravel-debugbar in development — it shows every query on every page load so N+1 problems are impossible to miss.
- ✕Mistake 3: Caching config/routes in development — Symptom: You add a new route or change a .env value and nothing happens. You restart the server, clear browser cache, still nothing — Fix: Never run php artisan config:cache or php artisan route:cache on a local development machine. These commands are for production only. If you accidentally ran them, clear with php artisan config:clear && php artisan route:clear && php artisan cache:clear.
Interview Questions on This Topic
- QExplain the Laravel request lifecycle from the moment a browser sends an HTTP request to the moment a response is returned. What are the key stages and what happens at each one?
- QWhat is the N+1 query problem in Eloquent, can you give a concrete example of when it occurs, and what are two different ways to solve it?
- QWhat is Laravel's Service Container and how does dependency injection work in a controller constructor? Why is this preferable to manually instantiating dependencies with new?
Frequently Asked Questions
Do I need to know PHP well before learning Laravel?
You need a solid grasp of PHP fundamentals — arrays, functions, classes, interfaces, and namespaces — before Laravel will make sense. Laravel leans heavily on object-oriented PHP patterns like dependency injection and traits. If you're comfortable writing a PHP class with methods and understand what 'static' and 'new' do, you're ready. If not, spend a week on plain PHP OOP first — it will make Laravel click much faster.
What is the difference between Laravel Breeze, Jetstream, and Fortify?
Breeze is the minimal authentication starter — login, registration, password reset, email verification, simple Blade or Vue/React views. It's the right choice for most projects. Jetstream is the full-featured option — adds two-factor authentication, team management, profile photos, and API tokens via Sanctum, using Livewire or Inertia.js. Fortify is the backend-only authentication layer that both sit on top of — you'd only use it directly if you're building a headless API and want full control over the frontend.
When should I use a Form Request instead of calling $request->validate() directly in the controller?
Use $request->validate() for simple one-off validations with two or three rules. Use a Form Request class when validation logic is complex, reused across multiple controller methods, or needs a custom authorize() check. The practical rule: if your controller's store() and update() methods both validate the same fields, extract a Form Request. It also keeps controllers lean — a controller method should orchestrate, not validate.
Written and reviewed by senior developers with real-world experience across enterprise, startup and open-source projects. Every article on TheCodeForge is written to be clear, accurate and genuinely useful — not just SEO filler.