Laravel is the most popular PHP framework in the world. It takes the pain out of web development by providing elegant syntax, powerful tools, and a thriving ecosystem — letting you build anything from simple blogs to large-scale enterprise applications.
This tutorial takes you from zero to building complete web applications and REST APIs with Laravel, covering everything you need for real-world development.
What you'll learn
| Topic | What you'll be able to do |
|---|---|
| Setup | Install Laravel and run your first app |
| Routing | Define routes for web and API |
| Controllers | Handle HTTP requests with controllers |
| Blade | Build dynamic HTML with Blade templates |
| Eloquent ORM | Query a database with expressive syntax |
| Migrations | Version-control your database schema |
| Authentication | Add login/register in minutes with Laravel Breeze |
| Middleware | Protect routes and transform requests |
| APIs | Build a JSON REST API |
| Real projects | 2 complete production-ready projects |
Why Laravel?
| Laravel | Symfony | CodeIgniter | Slim | Raw PHP | |
|---|---|---|---|---|---|
| Learning curve | Medium | Steep | Low | Low | None/Chaotic |
| Eloquent ORM | Built-in | Doctrine | None | None | None |
| CLI (Artisan) | Yes | Yes (Console) | Limited | No | No |
| Auth scaffolding | Yes (Breeze/Jetstream) | Limited | No | No | No |
| Ecosystem | Massive | Large | Small | Minimal | None |
| Best for | Web apps, APIs, SaaS, e-commerce | Enterprise, APIs | Simple projects | Micro-APIs | Never |
| Job market 2025 | #1 PHP framework | #2 | Declining | Niche | — |
Laravel wins because it combines developer happiness (expressive syntax, great docs) with production power (queues, caching, broadcasting, Horizon, Octane).
Prerequisites
Before starting, you should know:
- PHP basics (variables, functions, arrays, classes)
- HTML/CSS fundamentals
- Basic SQL (SELECT, INSERT, WHERE)
- Command line basics (cd, mkdir, ls)
1. Setup: Your First Laravel App
Requirements
- PHP 8.2+
- Composer (PHP package manager)
- MySQL or PostgreSQL
Install Laravel via Composer
composer create-project laravel/laravel my-app
cd my-app
php artisan serve
Visit http://localhost:8000 — you'll see the Laravel welcome page.
Project Structure
my-app/
├── app/
│ ├── Http/
│ │ ├── Controllers/ # Handle HTTP requests
│ │ └── Middleware/ # Request/response filters
│ └── Models/ # Eloquent models
├── bootstrap/ # App bootstrapping
├── config/ # Configuration files
├── database/
│ ├── migrations/ # Database schema versions
│ └── seeders/ # Test data seeders
├── public/ # Web root (index.php, assets)
├── resources/
│ ├── views/ # Blade templates
│ └── css/, js/ # Frontend assets
├── routes/
│ ├── web.php # Web routes
│ └── api.php # API routes
├── storage/ # Logs, cache, uploads
├── tests/ # Unit and feature tests
├── .env # Environment variables
└── artisan # CLI tool
Environment Configuration
.env stores your app's secrets — never commit this file:
APP_NAME=MyApp
APP_ENV=local
APP_KEY=base64:... # Generated automatically
APP_DEBUG=true
APP_URL=http://localhost
DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_PORT=3306
DB_DATABASE=myapp
DB_USERNAME=root
DB_PASSWORD=secret
Generate app key (do this on every fresh install):
php artisan key:generate
2. Routing
Routes map HTTP requests to code. All web routes live in routes/web.php.
Basic Routes
use Illuminate\Support\Facades\Route;
// Return a string
Route::get('/', function () {
return 'Hello, Laravel!';
});
// Return JSON
Route::get('/api/status', function () {
return response()->json(['status' => 'ok']);
});
// Route with parameter
Route::get('/users/{id}', function (int $id) {
return "User: {$id}";
});
// Optional parameter
Route::get('/posts/{slug?}', function (?string $slug = null) {
return $slug ?? 'all posts';
});
HTTP Method Routes
Route::get('/posts', [PostController::class, 'index']);
Route::post('/posts', [PostController::class, 'store']);
Route::get('/posts/{id}', [PostController::class, 'show']);
Route::put('/posts/{id}', [PostController::class, 'update']);
Route::delete('/posts/{id}', [PostController::class, 'destroy']);
// Shorthand: all 7 RESTful routes at once
Route::resource('posts', PostController::class);
Named Routes
Route::get('/posts/{id}', [PostController::class, 'show'])->name('posts.show');
// Generate URL by name
$url = route('posts.show', ['id' => 42]); // /posts/42
// Redirect to named route
return redirect()->route('posts.index');
Route Groups
// Prefix group
Route::prefix('admin')->group(function () {
Route::get('/dashboard', [AdminController::class, 'dashboard']);
Route::get('/users', [AdminController::class, 'users']);
});
// URLs: /admin/dashboard, /admin/users
// Middleware group
Route::middleware(['auth'])->group(function () {
Route::get('/profile', [ProfileController::class, 'show']);
Route::get('/settings', [SettingsController::class, 'show']);
});
// Prefix + middleware combined
Route::prefix('admin')
->middleware(['auth', 'admin'])
->name('admin.')
->group(function () {
Route::get('/dashboard', [AdminController::class, 'dashboard'])->name('dashboard');
// URL: /admin/dashboard, name: admin.dashboard
});
List All Routes
php artisan route:list
3. Controllers
Controllers group related request-handling logic into a class.
Create a Controller
php artisan make:controller PostController
# With resource methods:
php artisan make:controller PostController --resource
Basic Controller
<?php
// app/Http/Controllers/PostController.php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
class PostController extends Controller
{
// GET /posts
public function index()
{
return view('posts.index', ['posts' => Post::all()]);
}
// GET /posts/{id}
public function show(int $id)
{
$post = Post::findOrFail($id);
return view('posts.show', compact('post'));
}
// POST /posts
public function store(Request $request)
{
$validated = $request->validate([
'title' => 'required|string|max:255',
'body' => 'required|string',
]);
$post = Post::create($validated);
return redirect()->route('posts.show', $post->id)
->with('success', 'Post created!');
}
// PUT /posts/{id}
public function update(Request $request, Post $post) // Route model binding
{
$validated = $request->validate([
'title' => 'required|string|max:255',
'body' => 'required|string',
]);
$post->update($validated);
return redirect()->route('posts.show', $post->id)
->with('success', 'Post updated!');
}
// DELETE /posts/{id}
public function destroy(Post $post)
{
$post->delete();
return redirect()->route('posts.index')
->with('success', 'Post deleted!');
}
}
Route Model Binding
Laravel automatically injects the model when the parameter name matches:
// Route: Route::get('/posts/{post}', [PostController::class, 'show']);
public function show(Post $post) // Injects Post with that ID automatically
{
return view('posts.show', compact('post'));
// 404 automatically if not found
}
Request Validation
// Inline validation
$validated = $request->validate([
'title' => 'required|string|max:255',
'body' => 'required|string|min:10',
'email' => 'required|email|unique:users',
'age' => 'required|integer|min:18|max:120',
'image' => 'nullable|image|mimes:jpg,png,webp|max:2048',
'tags' => 'array',
'tags.*' => 'string|max:50',
]);
// Create a Form Request (reusable)
php artisan make:request StorePostRequest
<?php
// app/Http/Requests/StorePostRequest.php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class StorePostRequest extends FormRequest
{
public function authorize(): bool
{
return true; // Or check $this->user()->can(...)
}
public function rules(): array
{
return [
'title' => 'required|string|max:255',
'body' => 'required|string|min:10',
];
}
public function messages(): array
{
return [
'title.required' => 'Post title cannot be empty.',
];
}
}
// Usage in controller:
public function store(StorePostRequest $request)
{
// $request->validated() contains only validated data
Post::create($request->validated());
}
4. Blade Templates
Blade is Laravel's templating engine. Files use the .blade.php extension and live in resources/views/.
Blade Syntax
{{-- This is a comment --}}
{{-- Output (auto-escaped) --}}
<h1>{{ $title }}</h1>
{{-- Unescaped (dangerous — only for trusted HTML) --}}
<div>{!! $html !!}</div>
{{-- Conditionals --}}
@if ($user->isAdmin())
<span>Admin</span>
@elseif ($user->isModerator())
<span>Moderator</span>
@else
<span>User</span>
@endif
{{-- Unless --}}
@unless ($user->isPremium())
<a href="/upgrade">Upgrade</a>
@endunless
{{-- Loops --}}
@foreach ($posts as $post)
<article>
<h2>{{ $post->title }}</h2>
<p>{{ $loop->index }} of {{ $loop->count }}</p>
@if ($loop->first) <span>First post!</span> @endif
</article>
@endforeach
@forelse ($posts as $post)
<li>{{ $post->title }}</li>
@empty
<li>No posts found.</li>
@endforelse
{{-- $loop variable properties --}}
{{-- $loop->index, $loop->iteration, $loop->first, $loop->last, $loop->count --}}
Layouts with @extends and @section
{{-- resources/views/layouts/app.blade.php --}}
<!DOCTYPE html>
<html>
<head>
<title>@yield('title', 'My App')</title>
@stack('styles')
</head>
<body>
<nav>
<a href="/">Home</a>
<a href="/posts">Posts</a>
</nav>
<main>
@yield('content')
</main>
<footer>© 2025 My App</footer>
@stack('scripts')
</body>
</html>
{{-- resources/views/posts/index.blade.php --}}
@extends('layouts.app')
@section('title', 'All Posts')
@section('content')
<h1>Posts</h1>
@foreach ($posts as $post)
<div>
<h2><a href="{{ route('posts.show', $post) }}">{{ $post->title }}</a></h2>
<p>{{ $post->excerpt }}</p>
</div>
@endforeach
@endsection
@push('scripts')
<script src="/js/posts.js"></script>
@endpush
Components (Laravel 8+)
php artisan make:component Alert
<?php
// app/View/Components/Alert.php
namespace App\View\Components;
use Illuminate\View\Component;
class Alert extends Component
{
public function __construct(
public string $type = 'info',
public string $message = ''
) {}
public function render()
{
return view('components.alert');
}
}
{{-- resources/views/components/alert.blade.php --}}
<div class="alert alert-{{ $type }}">
{{ $message }}
{{ $slot }} {{-- Default slot for arbitrary content --}}
</div>
{{-- Usage --}}
<x-alert type="success" message="Saved!" />
<x-alert type="danger">
Something went wrong. <a href="#">Try again</a>.
</x-alert>
5. Eloquent ORM
Eloquent is Laravel's ActiveRecord ORM. Each model maps to a database table.
Create a Model + Migration
php artisan make:model Post -m # -m creates migration too
php artisan make:model Post -mcr # -c controller, -r resource
Migration
<?php
// database/migrations/2025_01_01_000000_create_posts_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('posts', function (Blueprint $table) {
$table->id(); // Auto-increment BIGINT primary key
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->string('title'); // VARCHAR(255)
$table->string('slug')->unique();
$table->text('body');
$table->text('excerpt')->nullable();
$table->boolean('published')->default(false);
$table->timestamp('published_at')->nullable();
$table->timestamps(); // created_at + updated_at
$table->softDeletes(); // deleted_at (soft delete support)
});
}
public function down(): void
{
Schema::dropIfExists('posts');
}
};
php artisan migrate # Run pending migrations
php artisan migrate:status # Show migration status
php artisan migrate:rollback # Rollback last batch
php artisan migrate:fresh # Drop all + re-run (dev only!)
Model Definition
<?php
// app/Models/Post.php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Database\Eloquent\Factories\HasFactory;
class Post extends Model
{
use HasFactory, SoftDeletes;
// Mass-assignable fields
protected $fillable = ['title', 'slug', 'body', 'excerpt', 'published', 'published_at', 'user_id'];
// Cast types automatically
protected $casts = [
'published' => 'boolean',
'published_at' => 'datetime',
];
// Relationships
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function comments(): HasMany
{
return $this->hasMany(Comment::class);
}
public function tags(): BelongsToMany
{
return $this->belongsToMany(Tag::class);
}
// Local scope
public function scopePublished($query)
{
return $query->where('published', true)
->whereNotNull('published_at');
}
// Accessor
public function getExcerptAttribute(): string
{
return $this->attributes['excerpt'] ?? substr($this->body, 0, 150) . '...';
}
}
Eloquent Queries
// CREATE
$post = Post::create([
'title' => 'Hello Laravel',
'slug' => 'hello-laravel',
'body' => 'My first post...',
'user_id' => auth()->id(),
]);
// READ
Post::all(); // All posts (use with caution on large tables)
Post::find(1); // By primary key (null if not found)
Post::findOrFail(1); // By primary key (throws 404 if not found)
Post::where('published', true)->get(); // Collection
Post::where('published', true)->first(); // Single model
// Chaining
Post::published() // Use scope
->with('user', 'tags') // Eager load (prevents N+1)
->orderBy('published_at', 'desc')
->paginate(15); // Paginate (returns LengthAwarePaginator)
// UPDATE
$post->update(['title' => 'New Title']); // On instance
Post::where('published', false)->update(['published' => true]); // Bulk
// DELETE
$post->delete(); // Soft delete if model uses SoftDeletes
$post->forceDelete(); // Permanent delete
// RESTORE (soft deletes)
Post::withTrashed()->find(1)->restore();
Post::onlyTrashed()->get(); // Only deleted records
Eloquent Column Types Reference
| Type | Migration Method | PHP Type After Cast |
|---|---|---|
| Auto-increment PK | $table->id() |
int |
| Integer | $table->integer('count') |
int (with cast) |
| String | $table->string('name') |
string |
| Long text | $table->text('body') |
string |
| Decimal | $table->decimal('price', 10, 2) |
float (with cast) |
| Boolean | $table->boolean('active') |
bool (with cast) |
| Datetime | $table->timestamp('published_at') |
Carbon (with cast) |
| JSON | $table->json('metadata') |
array (with cast) |
| Foreign key | $table->foreignId('user_id')->constrained() |
int |
Relationships
// ONE-TO-MANY: User has many Posts
// User model:
public function posts(): HasMany
{
return $this->hasMany(Post::class);
}
// Post model:
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
// Usage:
$user->posts; // Collection of posts
$post->user; // User instance
$user->posts()->create(['title' => 'New', ...]); // Create via relationship
// MANY-TO-MANY: Post has many Tags, Tag has many Posts
// Requires pivot table: post_tag (post_id, tag_id)
$post->tags()->attach($tagId); // Add tag
$post->tags()->detach($tagId); // Remove tag
$post->tags()->sync([1, 2, 3]); // Replace all tags
$post->tags; // Collection of tags
// HAS-MANY-THROUGH: Country → Users → Posts
public function posts(): HasManyThrough
{
return $this->hasManyThrough(Post::class, User::class);
}
Eager Loading (Prevent N+1)
// BAD — N+1 problem: 1 query for posts + N queries for each user
$posts = Post::all();
foreach ($posts as $post) {
echo $post->user->name; // Query per iteration
}
// GOOD — Eager load: 2 queries total
$posts = Post::with('user')->get();
foreach ($posts as $post) {
echo $post->user->name; // No extra query
}
// Multiple relationships
$posts = Post::with(['user', 'tags', 'comments.user'])->get();
// Conditional eager loading
$posts = Post::with(['comments' => function ($query) {
$query->where('approved', true)->orderBy('created_at', 'desc');
}])->get();
6. Seeders and Factories
Factory
php artisan make:factory PostFactory
<?php
// database/factories/PostFactory.php
namespace Database\Factories;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Str;
class PostFactory extends Factory
{
public function definition(): array
{
$title = fake()->sentence(4);
return [
'user_id' => \App\Models\User::factory(),
'title' => $title,
'slug' => Str::slug($title),
'body' => fake()->paragraphs(5, true),
'published' => fake()->boolean(70), // 70% true
'published_at' => fake()->optional()->dateTimeBetween('-1 year', 'now'),
];
}
}
Seeder
php artisan make:seeder PostSeeder
<?php
// database/seeders/PostSeeder.php
namespace Database\Seeders;
use Illuminate\Database\Seeder;
use App\Models\Post;
class PostSeeder extends Seeder
{
public function run(): void
{
Post::factory()->count(50)->create();
Post::factory()->count(10)->create(['published' => false]);
}
}
php artisan db:seed # Run DatabaseSeeder
php artisan db:seed --class=PostSeeder # Run specific seeder
php artisan migrate:fresh --seed # Fresh migrate + seed
7. Authentication with Laravel Breeze
Laravel Breeze scaffolds complete auth (login, register, password reset) in minutes.
composer require laravel/breeze --dev
php artisan breeze:install blade # Or: react, vue, api
npm install && npm run build
php artisan migrate
This generates:
routes/auth.php— auth routes (login, register, logout, password reset)app/Http/Controllers/Auth/— auth controllersresources/views/auth/— Blade views- Middleware integration
Protecting Routes
// routes/web.php
Route::middleware('auth')->group(function () {
Route::get('/dashboard', [DashboardController::class, 'index'])->name('dashboard');
Route::resource('posts', PostController::class)->except(['index', 'show']);
});
Accessing the Authenticated User
use Illuminate\Support\Facades\Auth;
// In controller
$user = Auth::user();
$user = auth()->user();
$userId = auth()->id();
$isLoggedIn = auth()->check();
// In Blade
@auth
<span>Welcome, {{ auth()->user()->name }}!</span>
<a href="{{ route('logout') }}">Logout</a>
@endauth
@guest
<a href="{{ route('login') }}">Login</a>
@endguest
8. Middleware
Middleware filters HTTP requests entering your application.
php artisan make:middleware EnsureUserIsAdmin
<?php
// app/Http/Middleware/EnsureUserIsAdmin.php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class EnsureUserIsAdmin
{
public function handle(Request $request, Closure $next): Response
{
if (! $request->user()?->isAdmin()) {
return redirect('/dashboard')->with('error', 'Access denied.');
}
return $next($request);
}
}
Register and use:
// bootstrap/app.php (Laravel 11)
->withMiddleware(function (Middleware $middleware) {
$middleware->alias([
'admin' => EnsureUserIsAdmin::class,
]);
})
// routes/web.php
Route::middleware(['auth', 'admin'])->prefix('admin')->group(function () {
Route::get('/dashboard', [AdminController::class, 'dashboard']);
});
Built-in Middleware
| Middleware | Purpose |
|---|---|
auth |
Require authenticated user |
auth:sanctum |
API token authentication |
guest |
Redirect authenticated users |
verified |
Require email verification |
throttle:60,1 |
Rate limit (60 req/min) |
cache.headers |
Set HTTP cache headers |
9. Building a REST API
Laravel makes building APIs straightforward. Use routes/api.php (routes are prefixed /api automatically).
API Controller
php artisan make:controller Api/PostController --api
<?php
// app/Http/Controllers/Api/PostController.php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Http\Resources\PostResource;
use App\Models\Post;
use Illuminate\Http\Request;
use Illuminate\Http\JsonResponse;
class PostController extends Controller
{
public function index(): JsonResponse
{
$posts = Post::with('user')
->published()
->paginate(15);
return PostResource::collection($posts)->response();
}
public function show(Post $post): JsonResponse
{
return new PostResource($post->load('user', 'tags'));
}
public function store(Request $request): JsonResponse
{
$validated = $request->validate([
'title' => 'required|string|max:255',
'body' => 'required|string',
]);
$post = $request->user()->posts()->create($validated);
return (new PostResource($post))
->response()
->setStatusCode(201);
}
public function update(Request $request, Post $post): JsonResponse
{
$this->authorize('update', $post); // Policy check
$validated = $request->validate([
'title' => 'sometimes|string|max:255',
'body' => 'sometimes|string',
]);
$post->update($validated);
return new PostResource($post);
}
public function destroy(Post $post): JsonResponse
{
$this->authorize('delete', $post);
$post->delete();
return response()->json(null, 204);
}
}
API Resources (Response Transformation)
php artisan make:resource PostResource
<?php
// app/Http/Resources/PostResource.php
namespace App\Http\Resources;
use Illuminate\Http\Resources\Json\JsonResource;
class PostResource extends JsonResource
{
public function toArray($request): array
{
return [
'id' => $this->id,
'title' => $this->title,
'slug' => $this->slug,
'excerpt' => $this->excerpt,
'published_at' => $this->published_at?->toISOString(),
'author' => [
'id' => $this->user->id,
'name' => $this->user->name,
],
'tags' => $this->whenLoaded('tags', fn () =>
$this->tags->pluck('name')
),
'created_at' => $this->created_at->toISOString(),
];
}
}
API Authentication with Sanctum
composer require laravel/sanctum
php artisan vendor:publish --provider="Laravel\Sanctum\SanctumServiceProvider"
php artisan migrate
// routes/api.php
use Laravel\Sanctum\Http\Middleware\EnsureFrontendRequestsAreStateful;
Route::post('/register', [AuthController::class, 'register']);
Route::post('/login', [AuthController::class, 'login']);
Route::middleware('auth:sanctum')->group(function () {
Route::get('/user', fn (Request $r) => $r->user());
Route::post('/logout', [AuthController::class, 'logout']);
Route::apiResource('posts', PostController::class);
});
<?php
// app/Http/Controllers/Api/AuthController.php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\User;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
use Illuminate\Validation\ValidationException;
class AuthController extends Controller
{
public function register(Request $request)
{
$validated = $request->validate([
'name' => 'required|string|max:255',
'email' => 'required|email|unique:users',
'password' => 'required|min:8|confirmed',
]);
$user = User::create([
'name' => $validated['name'],
'email' => $validated['email'],
'password' => Hash::make($validated['password']),
]);
$token = $user->createToken('api')->plainTextToken;
return response()->json(['token' => $token, 'user' => $user], 201);
}
public function login(Request $request)
{
$request->validate([
'email' => 'required|email',
'password' => 'required',
]);
$user = User::where('email', $request->email)->first();
if (! $user || ! Hash::check($request->password, $user->password)) {
throw ValidationException::withMessages([
'email' => ['The provided credentials are incorrect.'],
]);
}
$token = $user->createToken('api')->plainTextToken;
return response()->json(['token' => $token, 'user' => $user]);
}
public function logout(Request $request)
{
$request->user()->currentAccessToken()->delete();
return response()->json(['message' => 'Logged out']);
}
}
10. Artisan Commands
Artisan is Laravel's CLI. Run php artisan list for all commands.
| Command | Description |
|---|---|
php artisan serve |
Start dev server |
php artisan make:model Post -mcr |
Model + migration + controller (resource) |
php artisan make:controller PostController |
Create controller |
php artisan make:middleware AdminOnly |
Create middleware |
php artisan make:request StorePostRequest |
Form request |
php artisan make:resource PostResource |
API resource |
php artisan make:seeder PostSeeder |
Database seeder |
php artisan make:factory PostFactory |
Model factory |
php artisan make:policy PostPolicy --model=Post |
Authorization policy |
php artisan make:job SendEmail |
Queue job |
php artisan make:event PostPublished |
Event |
php artisan make:listener NotifySubscribers |
Event listener |
php artisan migrate |
Run migrations |
php artisan migrate:fresh --seed |
Reset + seed DB |
php artisan db:seed |
Run seeders |
php artisan route:list |
List all routes |
php artisan route:cache |
Cache routes (production) |
php artisan config:cache |
Cache config (production) |
php artisan queue:work |
Start queue worker |
php artisan tinker |
Interactive REPL |
Custom Artisan Command
php artisan make:command SendDailyDigest
<?php
// app/Console/Commands/SendDailyDigest.php
namespace App\Console\Commands;
use Illuminate\Console\Command;
class SendDailyDigest extends Command
{
protected $signature = 'digest:send {--dry-run : Preview without sending}';
protected $description = 'Send daily email digest to all subscribers';
public function handle(): int
{
$this->info('Starting digest...');
if ($this->option('dry-run')) {
$this->warn('DRY RUN — no emails sent.');
return Command::SUCCESS;
}
// ... actual logic
$this->info('Done!');
return Command::SUCCESS;
}
}
php artisan digest:send
php artisan digest:send --dry-run
11. Queues and Jobs
Queues let you defer slow tasks (emails, image processing) to background workers.
# Setup queue table (for database driver)
php artisan queue:table
php artisan migrate
# Create a job
php artisan make:job SendWelcomeEmail
<?php
// app/Jobs/SendWelcomeEmail.php
namespace App\Jobs;
use App\Models\User;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Mail;
class SendWelcomeEmail implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public int $tries = 3;
public int $timeout = 60;
public function __construct(public User $user) {}
public function handle(): void
{
Mail::to($this->user)->send(new WelcomeMail($this->user));
}
public function failed(\Throwable $exception): void
{
// Log failure, notify admin...
}
}
// Dispatch a job
SendWelcomeEmail::dispatch($user);
SendWelcomeEmail::dispatch($user)->delay(now()->addMinutes(5));
SendWelcomeEmail::dispatch($user)->onQueue('emails');
// Run the worker
php artisan queue:work
php artisan queue:work --queue=emails --tries=3
12. Testing
Laravel is built for testing with PHPUnit and its own TestCase.
php artisan make:test PostTest --unit # Unit test
php artisan make:test PostTest # Feature test
php artisan test # Run all tests
php artisan test --filter=PostTest # Run specific test
Feature Test Example
<?php
// tests/Feature/PostTest.php
namespace Tests\Feature;
use App\Models\Post;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class PostTest extends TestCase
{
use RefreshDatabase; // Wraps each test in a DB transaction
public function test_guests_cannot_create_posts(): void
{
$response = $this->postJson('/api/posts', [
'title' => 'Test Post',
'body' => 'Test body content here.',
]);
$response->assertStatus(401);
}
public function test_authenticated_user_can_create_post(): void
{
$user = User::factory()->create();
$response = $this->actingAs($user, 'sanctum')
->postJson('/api/posts', [
'title' => 'My New Post',
'body' => 'Some content here...',
]);
$response->assertStatus(201)
->assertJsonPath('data.title', 'My New Post');
$this->assertDatabaseHas('posts', [
'title' => 'My New Post',
'user_id' => $user->id,
]);
}
public function test_post_list_returns_only_published(): void
{
Post::factory()->count(5)->create(['published' => true]);
Post::factory()->count(3)->create(['published' => false]);
$response = $this->getJson('/api/posts');
$response->assertStatus(200)
->assertJsonCount(5, 'data');
}
public function test_user_cannot_delete_others_post(): void
{
$owner = User::factory()->create();
$attacker = User::factory()->create();
$post = Post::factory()->for($owner)->create();
$response = $this->actingAs($attacker, 'sanctum')
->deleteJson("/api/posts/{$post->id}");
$response->assertStatus(403);
$this->assertDatabaseHas('posts', ['id' => $post->id]);
}
}
Unit Test Example
<?php
// tests/Unit/PostTest.php
namespace Tests\Unit;
use App\Models\Post;
use Tests\TestCase;
class PostTest extends TestCase
{
public function test_excerpt_defaults_to_truncated_body(): void
{
$post = new Post([
'body' => str_repeat('a', 300),
]);
$this->assertStringEndsWith('...', $post->excerpt);
$this->assertLessThanOrEqual(153, strlen($post->excerpt));
}
}
Project 1: Blog API
Build a complete blog REST API with CRUD, auth, and pagination.
laravel new blog-api
cd blog-api
composer require laravel/sanctum
php artisan vendor:publish --provider="Laravel\Sanctum\SanctumServiceProvider"
// database/migrations: create posts, comments, tags, post_tag tables
// Models: User, Post (hasMany Comments, belongsToMany Tags), Comment, Tag
// Controllers: Api\AuthController, Api\PostController, Api\CommentController
// Resources: PostResource, CommentResource
// routes/api.php:
Route::post('/register', [AuthController::class, 'register']);
Route::post('/login', [AuthController::class, 'login']);
Route::middleware('auth:sanctum')->group(function () {
Route::post('/logout', [AuthController::class, 'logout']);
Route::apiResource('posts', PostController::class);
Route::apiResource('posts.comments', CommentController::class)->shallow();
});
Route::get('/posts', [PostController::class, 'index']); // Public
Route::get('/posts/{post}', [PostController::class, 'show']); // Public
Key endpoints:
GET /api/posts List published posts (paginated)
POST /api/posts Create post (auth required)
GET /api/posts/{id} Get single post
PUT /api/posts/{id} Update post (author only)
DELETE /api/posts/{id} Delete post (author only)
GET /api/posts/{id}/comments List comments
POST /api/posts/{id}/comments Add comment (auth required)
Project 2: Task Manager (Web App)
Full web app with Blade UI, auth, and CRUD.
laravel new task-manager
cd task-manager
composer require laravel/breeze --dev
php artisan breeze:install blade
npm install && npm run build
// Migration: tasks (user_id, title, description, status enum, due_date, priority)
// Model: Task (belongsTo User, scopes: pending/completed/overdue)
// Controller: TaskController (full CRUD + mark complete)
// Views: tasks/index.blade.php (list with filter), tasks/create.blade.php, tasks/edit.blade.php
// routes/web.php
Route::middleware('auth')->group(function () {
Route::resource('tasks', TaskController::class);
Route::patch('/tasks/{task}/complete', [TaskController::class, 'complete'])->name('tasks.complete');
});
Features:
- Create/edit/delete tasks with due dates and priorities
- Filter by status (pending, in-progress, completed)
- Mark task as complete with one click
- Dashboard showing overdue and due-today tasks
Common Mistakes
| Mistake | Problem | Fix |
|---|---|---|
Not using $fillable or $guarded |
Mass assignment vulnerability | Always define $fillable on models |
| Eager loading forgotten | N+1 query problem, slow pages | Use ->with('relation') |
Returning Model::all() in API |
Returns all rows + all columns | Use pagination + resources |
| Storing secrets in code | Exposed credentials | Use .env + never commit |
| Business logic in controllers | Fat controllers, hard to test | Move to Services or Actions |
migrate:fresh in production |
Destroys all data | Only use in dev; use migrate in prod |
| Skipping validation | Security vulnerabilities | Always validate in FormRequest or inline |
Using {!! $var !!} for user input |
XSS vulnerability | Use {{ $var }} (auto-escaped) |
Laravel vs Other PHP Frameworks
| Laravel | Symfony | CodeIgniter 4 | Slim 4 | |
|---|---|---|---|---|
| ORM | Eloquent (ActiveRecord) | Doctrine (DataMapper) | None built-in | None |
| Templating | Blade | Twig | None built-in | None |
| Auth scaffolding | Breeze/Jetstream | SecurityBundle | None | None |
| Queue system | Built-in | Messenger | None | None |
| Real-time | Broadcasting (Pusher/Reverb) | Mercure | None | None |
| File storage | Flysystem abstraction | Filesystem | None | None |
| Learning curve | Medium | Steep | Easy | Easy |
| Best for | Full web apps, SaaS, APIs | Enterprise, APIs | Simple apps | Micro-APIs |
| Version (2025) | Laravel 11 | Symfony 7 | 4.x | 4.x |
| Weekly npm-like installs | 500k+/week | 400k+/week | 100k+/week | 80k+/week |
Laravel Ecosystem
| Package | Purpose |
|---|---|
| Laravel Breeze | Minimal auth scaffolding (login, register, password reset) |
| Laravel Jetstream | Full auth with teams, 2FA, API tokens |
| Laravel Sanctum | SPA / mobile API authentication |
| Laravel Passport | Full OAuth2 server |
| Laravel Horizon | Redis queue monitoring dashboard |
| Laravel Telescope | Debug assistant (requests, queries, jobs, logs) |
| Laravel Reverb | First-party WebSocket server |
| Laravel Octane | Supercharge app with Swoole/RoadRunner |
| Laravel Forge | Server management & deployment |
| Laravel Vapor | Serverless deployment on AWS |
| Livewire | Full-stack components without writing JS |
| Inertia.js | SPA-like apps using React/Vue with Laravel backend |
| Filament | Admin panel builder |
| Spatie Packages | Permissions, media library, activity log, and 200+ more |
32-Week Learning Path
| Week | Topic | Goal |
|---|---|---|
| 1–2 | Setup + Routing + Controllers | Build simple routes with controllers |
| 3–4 | Blade Templates | Dynamic views with layouts |
| 5–6 | Eloquent ORM + Migrations | CRUD with a database |
| 7–8 | Relationships | One-to-many, many-to-many |
| 9–10 | Authentication (Breeze) | Login/register system |
| 11–12 | Middleware + Authorization (Policies) | Route protection |
| 13–14 | APIs + Sanctum | REST API with token auth |
| 15–16 | API Resources + Pagination | Proper API responses |
| 17–18 | Queues + Jobs + Email | Background processing |
| 19–20 | Testing (Feature + Unit) | TDD workflow in Laravel |
| 21–22 | Caching (Redis) | Performance optimization |
| 23–24 | Events + Listeners + Broadcasting | Real-time features |
| 25–26 | File Storage (S3/local) | Uploads and media |
| 27–28 | Livewire or Inertia.js | Modern frontend integration |
| 29–30 | Performance + Optimization | Eager loading, caching, Octane |
| 31–32 | Deployment (Forge/Vapor/VPS) | Ship to production |
Laravel Developer Roles & Salary
| Role | Experience | Salary (US) |
|---|---|---|
| Junior Laravel Developer | 0–2 years | $55k–$85k |
| Mid-level Laravel Developer | 2–5 years | $85k–$120k |
| Senior Laravel Developer | 5+ years | $120k–$160k |
| Laravel/PHP Tech Lead | 7+ years | $150k–$200k |
| Full Stack (Laravel + Vue/React) | 3–7 years | $100k–$150k |
Frequently Asked Questions
Is Laravel good for beginners?
Laravel is one of the best frameworks for PHP beginners because of its exceptional documentation, expressive syntax, and strong community. The learning curve is gentler than Symfony, and the built-in scaffolding (Breeze, Sanctum) handles complex patterns automatically. If you know basic PHP, you can build real apps within weeks.
Laravel vs WordPress: which should I choose?
WordPress is a CMS — great for content sites and blogs without custom development. Laravel is a full-stack PHP framework for building custom web applications. Choose WordPress for content-heavy sites with minimal custom logic. Choose Laravel for custom SaaS products, APIs, booking systems, dashboards, or anything with complex business logic.
Can Laravel be used for APIs?
Absolutely. Laravel is excellent for building REST APIs. Use routes/api.php, API controllers, Eloquent API Resources for response transformation, and Laravel Sanctum for authentication. For high-traffic APIs, consider Laravel Octane (Swoole) for significantly better throughput.
What database does Laravel work with?
Laravel Eloquent supports MySQL, PostgreSQL, SQLite, and SQL Server out of the box. MySQL and PostgreSQL are the most common choices in production. SQLite is perfect for local development and testing with RefreshDatabase.
Is Laravel still relevant in 2025?
Yes — Laravel remains the most popular PHP framework worldwide. With Laravel 11, Reverb (WebSockets), Octane, Horizon, and a massive ecosystem of first-party and Spatie packages, it covers everything from simple blogs to enterprise SaaS. The job market remains strong with 500k+ weekly Composer installs.
How do I deploy a Laravel app?
Common options: Laravel Forge (provision a VPS + auto-deploy from GitHub — easiest), Laravel Vapor (serverless on AWS), or manually on a VPS (Ubuntu + Nginx + PHP-FPM + MySQL). You'll need to set .env in production, run php artisan config:cache and php artisan route:cache, and point your web server to public/.