Building a Laravel 12 CRUD App with Test-Driven Development (TDD)

Author

Kritim Yantra

Apr 15, 2025

Building a Laravel 12 CRUD App with Test-Driven Development (TDD)

Laravel 12 continues to be a top choice for modern PHP developers, offering elegant syntax, powerful features, and a supportive ecosystem. In this blog, we'll walk through how to build a simple CRUD (Create, Read, Update, Delete) application using Test-Driven Development (TDD) β€” a methodology that promotes writing tests before the code.


🧠 Why TDD?

TDD helps you:

  • Write cleaner, more maintainable code
  • Catch bugs early
  • Document features through tests
  • Refactor with confidence

We'll build a simple "Posts" app where users can create, read, update, and delete posts.


πŸ› οΈ Prerequisites

  • PHP 8.2+
  • Composer
  • Laravel 12 (composer create-project laravel/laravel:^12.0 laravel-tdd-crud)
  • A database (MySQL or SQLite recommended)

πŸ§ͺ Step 1: Setting Up PHPUnit & Pest (Optional)

Laravel ships with PHPUnit out of the box, but you can also use Pest for a more elegant syntax.

composer require pestphp/pest --dev
php artisan pest:install

We'll stick with PHPUnit syntax here for simplicity.


πŸ“ Step 2: Create the Post Model and Migration

Let’s write a test that ensures a post can be created.

Create the test

php artisan make:test PostTest

In tests/Feature/PostTest.php:

use Illuminate\Foundation\Testing\RefreshDatabase;
use App\Models\Post;

public function test_a_post_can_be_created()
{
    $this->withoutExceptionHandling();

    $response = $this->post('/posts', [
        'title' => 'Test Post',
        'content' => 'This is a test post.',
    ]);

    $response->assertRedirect('/posts');
    $this->assertDatabaseHas('posts', ['title' => 'Test Post']);
}

Run it:

php artisan test

πŸ’₯ It will fail. Now let’s write just enough code to pass it.


βš™οΈ Step 3: Create the Post Model and Migration

php artisan make:model Post -m

Update the migration in database/migrations/...create_posts_table.php:

Schema::create('posts', function (Blueprint $table) {
    $table->id();
    $table->string('title');
    $table->text('content');
    $table->timestamps();
});

Run the migration:

php artisan migrate

Update Post.php:

protected $fillable = ['title', 'content'];

🧩 Step 4: Create the Controller & Routes

php artisan make:controller PostController

In routes/web.php:

use App\Http\Controllers\PostController;

Route::resource('posts', PostController::class);

Update PostController:

public function store(Request $request)
{
    Post::create($request->validate([
        'title' => 'required',
        'content' => 'required',
    ]));

    return redirect('/posts');
}

Now rerun the test. βœ… It should pass!


πŸ“– Step 5: Read (List and View a Single Post)

Add Test

public function test_posts_can_be_listed()
{
    $post = Post::factory()->create();

    $response = $this->get('/posts');

    $response->assertSee($post->title);
}

Add Route and Method

In PostController.php:

public function index()
{
    $posts = Post::latest()->get();
    return view('posts.index', compact('posts'));
}

Create resources/views/posts/index.blade.php:

@foreach ($posts as $post)
    <h2>{{ $post->title }}</h2>
@endforeach

βœ… Test passes.


✏️ Step 6: Update Post

Add Test

public function test_a_post_can_be_updated()
{
    $post = Post::factory()->create();

    $response = $this->put("/posts/{$post->id}", [
        'title' => 'Updated Title',
        'content' => 'Updated content.',
    ]);

    $response->assertRedirect('/posts');
    $this->assertDatabaseHas('posts', ['title' => 'Updated Title']);
}

Controller Update Method

public function update(Request $request, Post $post)
{
    $post->update($request->validate([
        'title' => 'required',
        'content' => 'required',
    ]));

    return redirect('/posts');
}

βœ… Test passes.


❌ Step 7: Delete Post

Add Test

public function test_a_post_can_be_deleted()
{
    $post = Post::factory()->create();

    $response = $this->delete("/posts/{$post->id}");

    $response->assertRedirect('/posts');
    $this->assertDatabaseMissing('posts', ['id' => $post->id]);
}

Controller Delete Method

public function destroy(Post $post)
{
    $post->delete();
    return redirect('/posts');
}

βœ… Green again!


πŸ§ͺ Final Test File Overview

Your PostTest.php should now include:

  • test_a_post_can_be_created
  • test_posts_can_be_listed
  • test_a_post_can_be_updated
  • test_a_post_can_be_deleted

πŸ“ˆ Best Practices

  • Use factories for cleaner test data.
  • Keep tests small and focused.
  • Use RefreshDatabase for test isolation.
  • Use Pest if you want a more expressive syntax.

πŸš€ Conclusion

With Laravel 12 and TDD, building reliable applications becomes a structured and testable process. By writing tests before implementation, you can avoid bugs, document expected behavior, and ensure maintainability over time.

Ready to scale your app? Add features like authentication, tagging, and comments β€” all using TDD!

Tags

Laravel Php

Comments

No comments yet. Be the first to comment!

Please log in to post a comment:

Sign in with Google

Related Posts