Laravel 11: Building a Complete Application from Scratch

Comprehensive guide to building a Laravel 11 application with authentication, REST API, Eloquent ORM, and deployment. Practical tutorial for beginners and intermediate developers.

Laravel 11 tutorial for building a complete application

Laravel 11 redefines modern PHP development with a streamlined architecture and optimized performance. This major release removes many default configuration files, introduces a leaner structure, and significantly improves the developer experience. This guide walks through building a complete task management application, from installation to deployment.

Laravel 11 Highlights

Laravel 11 adopts a minimalist structure: no more Kernel files, consolidated configuration in bootstrap/app.php, and a cleaner app/ directory. The framework requires PHP 8.2 minimum and natively integrates SQLite for quick startup.

Installation and Project Setup

Laravel 11 installs via Composer or the Laravel installer. The new project structure is leaner, with fewer configuration files to manage from the start.

bash
# terminal
# Install the Laravel installer (recommended)
composer global require laravel/installer

# Create a new Laravel 11 project
laravel new task-manager

# Or directly with Composer
composer create-project laravel/laravel task-manager

# Navigate to the project
cd task-manager

The installer offers several interactive options: choice of starter kit (Breeze, Jetstream), testing framework (Pest, PHPUnit), and database.

bash
# terminal
# Start the development server
php artisan serve

# Server starts at http://localhost:8000

Database Configuration

Laravel 11 uses SQLite by default, ideal for development. For production applications, configuration for MySQL or PostgreSQL happens in the .env file.

env
# .env
# MySQL configuration
DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_PORT=3306
DB_DATABASE=task_manager
DB_USERNAME=root
DB_PASSWORD=secret

# PostgreSQL configuration (alternative)
# DB_CONNECTION=pgsql
# DB_HOST=127.0.0.1
# DB_PORT=5432
# DB_DATABASE=task_manager
# DB_USERNAME=postgres
# DB_PASSWORD=secret

Database connection verification uses an Artisan command.

bash
# terminal
# Verify database connection
php artisan db:show

# Run default migrations
php artisan migrate

Creating Models and Migrations

Eloquent ORM simplifies data manipulation with expressive models. The make:model command generates the model, migration, controller, and factory in one go.

bash
# terminal
# Generate Task model with migration, factory, seeder, and controller
php artisan make:model Task -mfsc

# -m : migration
# -f : factory
# -s : seeder
# -c : controller

This command creates four essential files for complete task management.

php
<?php
// database/migrations/2026_01_14_000000_create_tasks_table.php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

// Migration to create the tasks table
return new class extends Migration
{
    public function up(): void
    {
        Schema::create('tasks', function (Blueprint $table) {
            // Auto-incrementing primary key
            $table->id();
            // Relationship with the owner user
            $table->foreignId('user_id')->constrained()->cascadeOnDelete();
            // Task title (required)
            $table->string('title');
            // Detailed description (optional)
            $table->text('description')->nullable();
            // Status: pending, in_progress, completed
            $table->string('status')->default('pending');
            // Priority: low, medium, high
            $table->string('priority')->default('medium');
            // Due date (optional)
            $table->date('due_date')->nullable();
            // Completion marker
            $table->timestamp('completed_at')->nullable();
            // Automatic timestamps (created_at, updated_at)
            $table->timestamps();
            // Soft deletes for trash functionality
            $table->softDeletes();

            // Indexes to optimize frequent queries
            $table->index(['user_id', 'status']);
            $table->index('due_date');
        });
    }

    public function down(): void
    {
        Schema::dropIfExists('tasks');
    }
};

The migration defines the table structure with relationships, indexes, and soft delete to prevent accidental data loss.

php
<?php
// app/Models/Task.php

namespace App\Models;

use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Database\Eloquent\Builder;

class Task extends Model
{
    // Traits for advanced functionality
    use HasFactory, SoftDeletes;

    // Mass-assignable attributes
    protected $fillable = [
        'user_id',
        'title',
        'description',
        'status',
        'priority',
        'due_date',
        'completed_at',
    ];

    // Automatic attribute casting
    protected function casts(): array
    {
        return [
            'due_date' => 'date',
            'completed_at' => 'datetime',
        ];
    }

    // Constants for valid statuses
    public const STATUS_PENDING = 'pending';
    public const STATUS_IN_PROGRESS = 'in_progress';
    public const STATUS_COMPLETED = 'completed';

    // Constants for priorities
    public const PRIORITY_LOW = 'low';
    public const PRIORITY_MEDIUM = 'medium';
    public const PRIORITY_HIGH = 'high';

    // Relationship: a task belongs to a user
    public function user(): BelongsTo
    {
        return $this->belongsTo(User::class);
    }

    // Scope: pending tasks
    public function scopePending(Builder $query): Builder
    {
        return $query->where('status', self::STATUS_PENDING);
    }

    // Scope: in-progress tasks
    public function scopeInProgress(Builder $query): Builder
    {
        return $query->where('status', self::STATUS_IN_PROGRESS);
    }

    // Scope: completed tasks
    public function scopeCompleted(Builder $query): Builder
    {
        return $query->where('status', self::STATUS_COMPLETED);
    }

    // Scope: overdue tasks
    public function scopeOverdue(Builder $query): Builder
    {
        return $query->where('due_date', '<', now())
                     ->whereNull('completed_at');
    }

    // Scope: tasks for a specific user
    public function scopeForUser(Builder $query, int $userId): Builder
    {
        return $query->where('user_id', $userId);
    }

    // Accessor: check if task is overdue
    public function getIsOverdueAttribute(): bool
    {
        return $this->due_date
            && $this->due_date->isPast()
            && !$this->completed_at;
    }

    // Method: mark as completed
    public function markAsCompleted(): void
    {
        $this->update([
            'status' => self::STATUS_COMPLETED,
            'completed_at' => now(),
        ]);
    }
}

The Task model uses Eloquent scopes for readable and reusable queries. Constants centralize valid values for statuses and priorities.

Soft Deletes

The SoftDeletes trait adds a deleted_at column. Deleted tasks do not disappear from the database but are marked as deleted. Use withTrashed() to include them in queries.

Setting Up Authentication with Breeze

Laravel Breeze provides complete authentication with Blade views or an API. Installation takes a few minutes and generates everything needed: routes, controllers, views, and tests.

bash
# terminal
# Install Laravel Breeze
composer require laravel/breeze --dev

# Install the Blade stack (traditional)
php artisan breeze:install blade

# Or for API only (SPA/mobile)
php artisan breeze:install api

# Compile assets
npm install && npm run build

# Run migrations (users, sessions tables, etc.)
php artisan migrate

Breeze generates authentication routes, controllers, and views for registration, login, password reset, and email verification.

php
<?php
// app/Models/User.php

namespace App\Models;

use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;

class User extends Authenticatable
{
    use HasFactory, Notifiable;

    protected $fillable = [
        'name',
        'email',
        'password',
    ];

    protected $hidden = [
        'password',
        'remember_token',
    ];

    protected function casts(): array
    {
        return [
            'email_verified_at' => 'datetime',
            'password' => 'hashed',
        ];
    }

    // Relationship: a user owns multiple tasks
    public function tasks(): HasMany
    {
        return $this->hasMany(Task::class);
    }

    // Shortcut: user's pending tasks
    public function pendingTasks(): HasMany
    {
        return $this->tasks()->pending();
    }

    // Shortcut: user's overdue tasks
    public function overdueTasks(): HasMany
    {
        return $this->tasks()->overdue();
    }
}

Ready to ace your Laravel interviews?

Practice with our interactive simulators, flashcards, and technical tests.

Creating Controllers and Routes

The controller orchestrates business logic between HTTP requests and the model. Laravel 11 encourages single-action controllers or RESTful resources.

php
<?php
// app/Http/Controllers/TaskController.php

namespace App\Http\Controllers;

use App\Models\Task;
use Illuminate\Http\Request;
use Illuminate\Http\RedirectResponse;
use Illuminate\View\View;
use Illuminate\Support\Facades\Gate;

class TaskController extends Controller
{
    // List tasks for the authenticated user
    public function index(Request $request): View
    {
        // Retrieve tasks with optional filtering
        $tasks = $request->user()
            ->tasks()
            ->when($request->status, fn($q, $status) => $q->where('status', $status))
            ->when($request->priority, fn($q, $priority) => $q->where('priority', $priority))
            ->orderBy('due_date')
            ->orderBy('priority', 'desc')
            ->paginate(15);

        return view('tasks.index', compact('tasks'));
    }

    // Creation form
    public function create(): View
    {
        return view('tasks.create');
    }

    // Store a new task
    public function store(Request $request): RedirectResponse
    {
        // Validate incoming data
        $validated = $request->validate([
            'title' => 'required|string|max:255',
            'description' => 'nullable|string|max:5000',
            'priority' => 'required|in:low,medium,high',
            'due_date' => 'nullable|date|after_or_equal:today',
        ]);

        // Create the task linked to the user
        $request->user()->tasks()->create($validated);

        return redirect()
            ->route('tasks.index')
            ->with('success', 'Task created successfully.');
    }

    // Display a task
    public function show(Task $task): View
    {
        // Verify user ownership
        Gate::authorize('view', $task);

        return view('tasks.show', compact('task'));
    }

    // Edit form
    public function edit(Task $task): View
    {
        Gate::authorize('update', $task);

        return view('tasks.edit', compact('task'));
    }

    // Update a task
    public function update(Request $request, Task $task): RedirectResponse
    {
        Gate::authorize('update', $task);

        $validated = $request->validate([
            'title' => 'required|string|max:255',
            'description' => 'nullable|string|max:5000',
            'status' => 'required|in:pending,in_progress,completed',
            'priority' => 'required|in:low,medium,high',
            'due_date' => 'nullable|date',
        ]);

        // Update completed_at if status = completed
        if ($validated['status'] === Task::STATUS_COMPLETED && !$task->completed_at) {
            $validated['completed_at'] = now();
        }

        $task->update($validated);

        return redirect()
            ->route('tasks.index')
            ->with('success', 'Task updated.');
    }

    // Delete a task
    public function destroy(Task $task): RedirectResponse
    {
        Gate::authorize('delete', $task);

        $task->delete();

        return redirect()
            ->route('tasks.index')
            ->with('success', 'Task deleted.');
    }

    // Mark as completed (quick action)
    public function complete(Task $task): RedirectResponse
    {
        Gate::authorize('update', $task);

        $task->markAsCompleted();

        return back()->with('success', 'Task completed!');
    }
}

The controller uses Gates for authorization, built-in validation, and route model binding for concise and secure code.

Defining Routes

Routes define URLs and associate them with controller actions. Laravel 11 centralizes routes in routes/web.php.

php
<?php
// routes/web.php

use App\Http\Controllers\TaskController;
use App\Http\Controllers\ProfileController;
use Illuminate\Support\Facades\Route;

// Public homepage
Route::get('/', function () {
    return view('welcome');
});

// Routes protected by authentication
Route::middleware(['auth', 'verified'])->group(function () {
    // Dashboard with statistics
    Route::get('/dashboard', function () {
        $user = auth()->user();

        return view('dashboard', [
            'totalTasks' => $user->tasks()->count(),
            'pendingTasks' => $user->tasks()->pending()->count(),
            'completedTasks' => $user->tasks()->completed()->count(),
            'overdueTasks' => $user->tasks()->overdue()->count(),
        ]);
    })->name('dashboard');

    // RESTful routes for tasks
    Route::resource('tasks', TaskController::class);

    // Quick action to complete a task
    Route::patch('/tasks/{task}/complete', [TaskController::class, 'complete'])
        ->name('tasks.complete');

    // Profile routes (generated by Breeze)
    Route::get('/profile', [ProfileController::class, 'edit'])->name('profile.edit');
    Route::patch('/profile', [ProfileController::class, 'update'])->name('profile.update');
    Route::delete('/profile', [ProfileController::class, 'destroy'])->name('profile.destroy');
});

// Authentication routes (generated by Breeze)
require __DIR__.'/auth.php';

The Route::resource() method automatically generates seven standard RESTful routes: index, create, store, show, edit, update, and destroy.

Setting Up Authorization Policies

Policies centralize authorization logic for each model. They determine who can perform which actions on resources.

bash
# terminal
# Generate the policy for Task
php artisan make:policy TaskPolicy --model=Task
php
<?php
// app/Policies/TaskPolicy.php

namespace App\Policies;

use App\Models\Task;
use App\Models\User;

class TaskPolicy
{
    // Check before all methods
    // Returning true bypasses all checks (admin)
    public function before(User $user, string $ability): ?bool
    {
        if ($user->is_admin) {
            return true;
        }

        return null; // Continue to specific methods
    }

    // Can view the list of tasks
    public function viewAny(User $user): bool
    {
        return true; // Any authenticated user
    }

    // Can view a specific task
    public function view(User $user, Task $task): bool
    {
        return $user->id === $task->user_id;
    }

    // Can create a task
    public function create(User $user): bool
    {
        return true;
    }

    // Can update a task
    public function update(User $user, Task $task): bool
    {
        return $user->id === $task->user_id;
    }

    // Can delete a task
    public function delete(User $user, Task $task): bool
    {
        return $user->id === $task->user_id;
    }

    // Can restore a soft-deleted task
    public function restore(User $user, Task $task): bool
    {
        return $user->id === $task->user_id;
    }

    // Can permanently delete
    public function forceDelete(User $user, Task $task): bool
    {
        return $user->id === $task->user_id;
    }
}

Laravel automatically discovers policies through naming conventions. The TaskPolicy applies to the Task model.

Policy Security

Policies are essential for security. Without them, a user could manipulate URLs to access other users' tasks. Always verify resource ownership.

Building a REST API

Laravel simplifies creating RESTful APIs with dedicated routes, JSON resources, and Sanctum for authentication.

bash
# terminal
# Install Sanctum (included by default since Laravel 11)
php artisan install:api

# This command:
# - Publishes Sanctum migrations
# - Adds the HasApiTokens trait to the User model
# - Configures API routes
php
<?php
// app/Http/Controllers/Api/TaskController.php

namespace App\Http\Controllers\Api;

use App\Http\Controllers\Controller;
use App\Http\Resources\TaskResource;
use App\Http\Resources\TaskCollection;
use App\Models\Task;
use Illuminate\Http\Request;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;

class TaskController extends Controller
{
    // Paginated list of tasks
    public function index(Request $request): AnonymousResourceCollection
    {
        $tasks = $request->user()
            ->tasks()
            ->when($request->status, fn($q, $s) => $q->where('status', $s))
            ->when($request->priority, fn($q, $p) => $q->where('priority', $p))
            ->when($request->boolean('overdue'), fn($q) => $q->overdue())
            ->latest()
            ->paginate($request->input('per_page', 15));

        return TaskResource::collection($tasks);
    }

    // Create a task
    public function store(Request $request): JsonResponse
    {
        $validated = $request->validate([
            'title' => 'required|string|max:255',
            'description' => 'nullable|string|max:5000',
            'priority' => 'required|in:low,medium,high',
            'due_date' => 'nullable|date|after_or_equal:today',
        ]);

        $task = $request->user()->tasks()->create($validated);

        return response()->json([
            'message' => 'Task created successfully.',
            'data' => new TaskResource($task),
        ], 201);
    }

    // Display a task
    public function show(Task $task): TaskResource
    {
        $this->authorize('view', $task);

        return new TaskResource($task);
    }

    // Update a task
    public function update(Request $request, Task $task): TaskResource
    {
        $this->authorize('update', $task);

        $validated = $request->validate([
            'title' => 'sometimes|required|string|max:255',
            'description' => 'nullable|string|max:5000',
            'status' => 'sometimes|required|in:pending,in_progress,completed',
            'priority' => 'sometimes|required|in:low,medium,high',
            'due_date' => 'nullable|date',
        ]);

        if (isset($validated['status'])
            && $validated['status'] === Task::STATUS_COMPLETED
            && !$task->completed_at) {
            $validated['completed_at'] = now();
        }

        $task->update($validated);

        return new TaskResource($task->fresh());
    }

    // Delete a task
    public function destroy(Task $task): JsonResponse
    {
        $this->authorize('delete', $task);

        $task->delete();

        return response()->json([
            'message' => 'Task deleted successfully.',
        ]);
    }
}

API Resources for Data Transformation

API resources transform Eloquent models into structured JSON responses, precisely controlling exposed data.

php
<?php
// app/Http/Resources/TaskResource.php

namespace App\Http\Resources;

use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;

class TaskResource extends JsonResource
{
    public function toArray(Request $request): array
    {
        return [
            'id' => $this->id,
            'title' => $this->title,
            'description' => $this->description,
            'status' => $this->status,
            'priority' => $this->priority,
            'due_date' => $this->due_date?->format('Y-m-d'),
            'is_overdue' => $this->is_overdue,
            'completed_at' => $this->completed_at?->toISOString(),
            'created_at' => $this->created_at->toISOString(),
            'updated_at' => $this->updated_at->toISOString(),
            // Conditional inclusion of the user
            'user' => $this->whenLoaded('user', fn() => [
                'id' => $this->user->id,
                'name' => $this->user->name,
            ]),
        ];
    }
}

API Routes with Sanctum Authentication

php
<?php
// routes/api.php

use App\Http\Controllers\Api\TaskController;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route;

// Route to retrieve the authenticated user
Route::get('/user', function (Request $request) {
    return $request->user();
})->middleware('auth:sanctum');

// API routes protected by Sanctum
Route::middleware('auth:sanctum')->group(function () {
    Route::apiResource('tasks', TaskController::class);

    // Dashboard statistics
    Route::get('/dashboard/stats', function (Request $request) {
        $user = $request->user();

        return response()->json([
            'total' => $user->tasks()->count(),
            'pending' => $user->tasks()->pending()->count(),
            'in_progress' => $user->tasks()->inProgress()->count(),
            'completed' => $user->tasks()->completed()->count(),
            'overdue' => $user->tasks()->overdue()->count(),
        ]);
    });
});

Automated Testing with Pest

Laravel 11 integrates Pest PHP by default, an elegant and expressive testing framework. Tests ensure the application works correctly after each modification.

php
<?php
// tests/Feature/TaskTest.php

use App\Models\Task;
use App\Models\User;

// Test: a user can view their tasks
test('user can view their tasks', function () {
    $user = User::factory()->create();
    $tasks = Task::factory()->count(5)->for($user)->create();

    $response = $this->actingAs($user)->get('/tasks');

    $response->assertOk();
    $response->assertViewHas('tasks');
});

// Test: a user can create a task
test('user can create a task', function () {
    $user = User::factory()->create();

    $response = $this->actingAs($user)->post('/tasks', [
        'title' => 'New task',
        'description' => 'Task description',
        'priority' => 'high',
        'due_date' => now()->addDays(7)->format('Y-m-d'),
    ]);

    $response->assertRedirect('/tasks');

    $this->assertDatabaseHas('tasks', [
        'user_id' => $user->id,
        'title' => 'New task',
        'priority' => 'high',
    ]);
});

// Test: title validation required
test('task title is required', function () {
    $user = User::factory()->create();

    $response = $this->actingAs($user)->post('/tasks', [
        'title' => '',
        'priority' => 'medium',
    ]);

    $response->assertSessionHasErrors('title');
});

// Test: a user cannot view another user's task
test('user cannot view another users task', function () {
    $user = User::factory()->create();
    $otherUser = User::factory()->create();
    $task = Task::factory()->for($otherUser)->create();

    $response = $this->actingAs($user)->get("/tasks/{$task->id}");

    $response->assertForbidden();
});

// Test: mark a task as completed
test('user can complete a task', function () {
    $user = User::factory()->create();
    $task = Task::factory()->for($user)->create(['status' => 'pending']);

    $response = $this->actingAs($user)->patch("/tasks/{$task->id}/complete");

    $response->assertRedirect();

    $task->refresh();
    expect($task->status)->toBe('completed');
    expect($task->completed_at)->not->toBeNull();
});

// API test: retrieve tasks
test('api returns paginated tasks', function () {
    $user = User::factory()->create();
    Task::factory()->count(20)->for($user)->create();

    $response = $this->actingAs($user, 'sanctum')
        ->getJson('/api/tasks?per_page=10');

    $response->assertOk()
        ->assertJsonCount(10, 'data')
        ->assertJsonStructure([
            'data' => [
                '*' => ['id', 'title', 'status', 'priority', 'due_date'],
            ],
            'links',
            'meta',
        ]);
});

Running tests with Pest.

bash
# terminal
# Run all tests
php artisan test

# Tests with code coverage
php artisan test --coverage

# Tests for a specific file
php artisan test tests/Feature/TaskTest.php

# Parallel tests (faster)
php artisan test --parallel

Production Optimizations

Before deployment, several optimizations improve Laravel application performance.

bash
# terminal
# Cache configuration
php artisan config:cache

# Cache routes
php artisan route:cache

# Cache views
php artisan view:cache

# Optimize Composer autoloader
composer install --optimize-autoloader --no-dev

# Generate asset manifest
npm run build

Production .env File Configuration

env
# .env.production
APP_NAME="Task Manager"
APP_ENV=production
APP_DEBUG=false
APP_URL=https://taskmanager.example.com

# Cache and session via Redis (recommended)
CACHE_DRIVER=redis
SESSION_DRIVER=redis
QUEUE_CONNECTION=redis

REDIS_HOST=127.0.0.1
REDIS_PASSWORD=null
REDIS_PORT=6379

# Database
DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_DATABASE=task_manager_prod
DB_USERNAME=app_user
DB_PASSWORD=strong_password_here

# Mail
MAIL_MAILER=smtp
MAIL_HOST=smtp.mailgun.org
MAIL_PORT=587
MAIL_USERNAME=postmaster@taskmanager.example.com
MAIL_PASSWORD=secret
MAIL_ENCRYPTION=tls
Sensitive Variables

Never commit the .env file to the Git repository. Use .env.example as a template and configure sensitive variables directly on the production server or via secret managers.

Conclusion

Laravel 11 significantly simplifies modern PHP development with its streamlined structure and intelligent conventions. This guide covered the fundamentals for building a complete application: Eloquent models, authentication, RESTful controllers, authorization policies, API with Sanctum, and automated tests.

Checklist for Quality Laravel Applications

  • ✅ Use migrations for all schema modifications
  • ✅ Implement policies to secure resource access
  • ✅ Validate all user input in controllers
  • ✅ Use API resources to control exposed data
  • ✅ Write tests for critical features
  • ✅ Configure cache and optimizations before deployment
  • ✅ Secure sensitive environment variables

Start practicing!

Test your knowledge with our interview simulators and technical tests.

The Laravel ecosystem continues to evolve with packages like Livewire for reactive interfaces, Horizon for queue management, and Octane for exceptional performance. Mastering these fundamentals opens the door to robust and maintainable PHP applications.

Tags

#laravel
#php
#rest api
#eloquent
#tutorial

Share

Related articles