Muhammad Yasir Amin

Loading websiteโ€ฆ

More from Muhammad Yasir Amin

Build Along With Me

Practical walkthroughs on Laravel, React, APIs and deployment workflows.

Subscribe on YouTube

Laravel 13 CRUD Application Tutorial: Resource Controller, Form Requests and Blade

๐Ÿ› ๏ธ Laravel Tutorials5 min read

Laravel 13 CRUD Application Tutorial: Resource Controller, Form Requests and Blade

Muhammad Yasir Amin

Muhammad Yasir Amin

Senior Full Stack Engineerโ€ขNov 12, 2026
Laravel 13 CRUD Application Tutorial: Resource Controller, Form Requests and Blade

To build a CRUD application in Laravel 13, create a model with its migration, validate input with a form request, generate a resource controller with php artisan make:controller ProductController --resource --model=Product, and register it with Route::resource('products', ProductController::class). That single route line maps all seven actions: index, create, store, show, edit, update and destroy.

CRUD is where most Laravel projects start, and doing it cleanly from the first resource sets the pattern for the rest of the codebase. Below is the structure I use: thin controller, validation in a form request, route model binding and a feature test for each action.

Prerequisites

  • Laravel 13.x on PHP 8.3 or newer, created with laravel new or composer create-project laravel/laravel.
  • A database. The default SQLite setup works without extra configuration.
  • Pest, which is the default test runner in new Laravel projects.

Step 1: Create the model, migration and factory

Code
php artisan make:model Product -mf

Define the table in the new migration:

PHP
Schema::create('products', function (Blueprint $table) {
    $table->id();
    $table->string('name');
    $table->text('description')->nullable();
    $table->decimal('price', 10, 2);
    $table->unsignedInteger('stock')->default(0);
    $table->timestamps();
});

Run php artisan migrate, then allow mass assignment and cast the price in app/Models/Product.php:

JavaScript
<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;

class Product extends Model
{
    use HasFactory;

    protected $fillable = ['name', 'description', 'price', 'stock'];

    protected function casts(): array
    {
        return [
            'price' => 'decimal:2',
            'stock' => 'integer',
        ];
    }
}

Step 2: Validate with a form request

Code
php artisan make:request ProductRequest
JavaScript
<?php

namespace App\Http\Requests;

use Illuminate\Foundation\Http\FormRequest;

class ProductRequest extends FormRequest
{
    public function authorize(): bool
    {
        return true; // replace with a policy check in real apps
    }

    public function rules(): array
    {
        return [
            'name' => ['required', 'string', 'max:255'],
            'description' => ['nullable', 'string', 'max:5000'],
            'price' => ['required', 'numeric', 'min:0', 'max:99999999.99'],
            'stock' => ['required', 'integer', 'min:0'],
        ];
    }
}

One request class serves both store and update here because the rules are identical. When they differ, for example a unique rule that must ignore the current record, create separate classes.

Step 3: Build the resource controller

Code
php artisan make:controller ProductController --resource --model=Product
JavaScript
<?php

namespace App\Http\Controllers;

use App\Http\Requests\ProductRequest;
use App\Models\Product;
use Illuminate\Http\RedirectResponse;
use Illuminate\View\View;

class ProductController extends Controller
{
    public function index(): View
    {
        return view('products.index', [
            'products' => Product::latest()->paginate(10),
        ]);
    }

    public function create(): View
    {
        return view('products.form', ['product' => new Product]);
    }

    public function store(ProductRequest $request): RedirectResponse
    {
        $product = Product::create($request->validated());

        return redirect()->route('products.show', $product)->with('status', 'Product created.');
    }

    public function show(Product $product): View
    {
        return view('products.show', compact('product'));
    }

    public function edit(Product $product): View
    {
        return view('products.form', compact('product'));
    }

    public function update(ProductRequest $request, Product $product): RedirectResponse
    {
        $product->update($request->validated());

        return redirect()->route('products.show', $product)->with('status', 'Product updated.');
    }

    public function destroy(Product $product): RedirectResponse
    {
        $product->delete();

        return redirect()->route('products.index')->with('status', 'Product deleted.');
    }
}

Route model binding injects the Product for {product} and returns a 404 automatically if it does not exist. $request->validated() only contains fields that passed validation, which protects you from unexpected input.

Step 4: Register the routes

CSS
use App\Http\Controllers\ProductController;

Route::resource('products', ProductController::class)->middleware('auth');

Run php artisan route:list --name=products to see the seven generated routes and their names, such as products.edit.

Step 5: Create the Blade views

A shared form for create and edit keeps the markup in one place. In resources/views/products/form.blade.php:

Shell
<form method="POST"
      action="{{ $product->exists ? route('products.update', $product) : route('products.store') }}">
    @csrf
    @if ($product->exists)
        @method('PUT')
    @endif

    <input name="name" value="{{ old('name', $product->name) }}" required>
    @error('name') <p>{{ $message }}</p> @enderror

    <textarea name="description">{{ old('description', $product->description) }}</textarea>
    <input name="price" type="number" step="0.01" value="{{ old('price', $product->price) }}" required>
    <input name="stock" type="number" value="{{ old('stock', $product->stock ?? 0) }}" required>

    <button type="submit">Save</button>
</form>

In index.blade.php, loop over $products, link to route('products.edit', $product), and render {{ $products->links() }} for pagination. The delete button must be a small form with @csrf and @method('DELETE'), because browsers cannot send DELETE requests from links. Show the flash message with @if (session('status')).

Common mistakes

  • Using $request->all(). Pass $request->validated() to create and update instead.
  • Forgetting @method('PUT'). HTML forms only send GET and POST; without method spoofing the update route is never hit.
  • Deleting with a GET link. Search engine crawlers and link previews can trigger GET requests.
  • No authorization. authorize() returning true is fine for a tutorial, not for production. Add a policy.
  • Loading everything. Use paginate() on the index page, never all().

How to verify it works

Feature tests prove every action. A compact Pest file covering create, update and delete:

JavaScript
<?php

use App\Models\Product;
use App\Models\User;

beforeEach(fn () => $this->actingAs(User::factory()->create()));

it('creates a product', function () {
    $this->post('/products', ['name' => 'Desk', 'price' => 199.5, 'stock' => 3])
        ->assertRedirect();

    $this->assertDatabaseHas('products', ['name' => 'Desk', 'stock' => 3]);
});

it('validates input', function () {
    $this->post('/products', ['name' => '', 'price' => -1, 'stock' => 0])
        ->assertSessionHasErrors(['name', 'price']);
});

it('updates and deletes a product', function () {
    $product = Product::factory()->create();

    $this->put("/products/{$product->id}", ['name' => 'Chair', 'price' => 49, 'stock' => 1])
        ->assertRedirect();
    expect($product->fresh()->name)->toBe('Chair');

    $this->delete("/products/{$product->id}")->assertRedirect('/products');
    $this->assertModelMissing($product);
});

Define the factory fields in database/factories/ProductFactory.php first, then run php artisan test.

FAQ

How do I build a JSON API CRUD instead?

Use php artisan make:controller Api/ProductController --api --model=Product and Route::apiResource(), which skips the create and edit form routes, and return API resources instead of views.

Should I use soft deletes?

If deleted records might need to be restored, add $table->softDeletes() and the SoftDeletes trait. delete() then sets deleted_at instead of removing the row.

Can I generate everything at once?

Yes. php artisan make:model Product -a creates the migration, factory, seeder, policy, form requests and resource controller in one go.

References

Article Index & Topic Tags
LaravelEloquentValidationCrud

YouTube Channel

Build Along With Me

Practical walkthroughs on Laravel, React, APIs and deployment workflows.

Muhammad Yasir Amin
Available for Contracts
Author & Solutions Architect

Senior Full Stack Engineer

Independent Senior Full Stack Engineer working on Laravel, React and Next.js applications, APIs and delivery workflows.

Laravel & PHP ApplicationsReact & Next.js InterfacesREST APIs & IntegrationsDatabase Queries & DebuggingDocker Development WorkflowsClear Scope & Handover
Discuss Your Next Step

What Does Your Application Need Next?

Share your current system, the problem you want to solve and what a useful result looks like. We can identify the right starting point and agree on a practical scope.

Technical Audit

Find the Next Useful Fix

Application Development

Scoped, Reviewable Milestones

Ongoing Support

Agreed Capacity and Priorities

Share your goals, current stack and requirements before the call.