Home PHP Laravel Explained: Why It's the PHP Framework That Actually Makes Sense

Laravel Explained: Why It's the PHP Framework That Actually Makes Sense

In Plain English 🔥
Imagine you're building a house. You could dig your own foundations, manufacture your own bricks, and wire the electricity yourself — or you could use a construction company that already has all those systems ready, tested, and standardised. Laravel is that construction company for PHP web applications. It hands you pre-built tools for the most common jobs — routing URLs, talking to databases, sending emails, handling logins — so you spend your time building the rooms, not inventing concrete.
⚡ Quick Answer
Imagine you're building a house. You could dig your own foundations, manufacture your own bricks, and wire the electricity yourself — or you could use a construction company that already has all those systems ready, tested, and standardised. Laravel is that construction company for PHP web applications. It hands you pre-built tools for the most common jobs — routing URLs, talking to databases, sending emails, handling logins — so you spend your time building the rooms, not inventing concrete.

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.

RequestLifecycleDemo.php · PHP
1234567891011121314151617181920212223242526272829303132333435363738
<?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(),
        ]);
    });
});
▶ Output
GET /ping → HTTP 200
{
"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
🔥
Why Resource Routes Matter:Route::resource() is not just shorthand — it enforces RESTful naming conventions that your whole team understands instantly. When a new developer joins, they don't need to read your routing file to know that `DELETE /articles/5` calls `ArticleController@destroy`. Convention does the documentation for you.

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.

ArticleEloquentExample.php · PHP
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889
<?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.');
    }
}
▶ Output
// After visiting GET /articles:
// 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.'
⚠️
Watch Out — The N+1 Query Problem:If you loop over articles and access `$article->author` inside the loop without eager loading, Laravel fires one query per article. With 100 articles that's 101 queries. Always use `with('author')` in the controller when you know you'll need the relationship in the view. Install the Laravel Debugbar package in development — it shows you exactly how many queries each page fires.

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.

BladeTemplateSystem.blade.php · PHP
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071
{{-- 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 }}
                &middot;
                {{-- Carbon's diffForHumans() gives '3 days ago' style output --}}
                {{ $article->published_at->diffForHumans() }}
                &middot;
                {{ $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
▶ Output
<!-- Rendered HTML sent to the browser (abbreviated): -->
<!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>
⚠️
Pro Tip — Use route() Everywhere:Never hardcode URLs in Blade like href="/articles/42". Always use route('articles.show', $article). When you rename a URI in routes/web.php, every link in every template updates automatically. Hardcoded URLs are the reason refactors become nightmares.

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.

ArtisanWorkflow.php · PHP
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990
<?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!',
        ];
    }
}
▶ Output
// Terminal output from: php artisan make:model Article -mrc --requests
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,
}
⚠️
Pro Tip — Tinker is Your Best Debugging Friend:Before you write a complex Eloquent query in a controller, test it in Tinker first. You get instant feedback with your real database, real models, and real relationships. It's the fastest way to go from 'I think this query works' to 'I know this query works'. Run `php artisan tinker` and start experimenting.
Feature / AspectRaw PHPLaravel
RoutingParse $_SERVER['REQUEST_URI'] manuallyRoute::get('/path', Handler::class) — named, grouped, middleware-aware
Database accessPDO with manual prepared statementsEloquent ORM + Query Builder — fluent, safe, relationship-aware
Input validationCustom if/else logic per form fieldDeclarative rules array — auto 422 response with JSON error bag
AuthenticationSessions + password_hash() + manual checksphp artisan make:auth or Laravel Breeze/Jetstream — fully featured in minutes
Templatingecho + htmlspecialchars() everywhereBlade — auto-escaping, inheritance, components, zero runtime overhead
Database migrationsManual SQL scripts shared via SlackVersion-controlled PHP migration files run with php artisan migrate
TestingPHPUnit wired up manuallyPHPUnit + Pest pre-configured, HTTP test helpers, database factories built-in
CachingCustom Redis/Memcached integrationUnified Cache facade — swap drivers (Redis, file, array) via .env config
Queue/JobsCron jobs + custom queue tablesQueue 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.

🔥
TheCodeForge Editorial Team Verified Author

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.

← PreviousPHP and MongoDBNext →Laravel MVC Pattern
Forged with 🔥 at TheCodeForge.io — Where Developers Are Forged