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 neworcomposer 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
php artisan make:model Product -mfDefine the table in the new migration:
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:
<?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
php artisan make:request ProductRequest<?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
php artisan make:controller ProductController --resource --model=Product<?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
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:
<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()tocreateandupdateinstead. - 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()returningtrueis fine for a tutorial, not for production. Add a policy. - Loading everything. Use
paginate()on the index page, neverall().
How to verify it works
Feature tests prove every action. A compact Pest file covering create, update and delete:
<?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.



