You installed Laravel, the welcome page loads, and now you are not sure what to build first. Many online tutorials mix Laravel 8 conventions with newer ones or pull in packages you do not need yet. The result is a half-working CRUD and errors you cannot explain.
This guide builds a complete product CRUD in Laravel 11 in order: table, model, controller, validation, views, search, and deletion. Each step explains why it exists, so you can repeat the pattern for customers, categories, or orders without copying blindly.
Setup
You need PHP 8.2+, Composer, and a database. If you are still assembling your toolkit, see 15 free tools for beginner web developers.
composer create-project laravel/laravel shop
cd shop
php artisan serve
Laravel 11 defaults to SQLite, which is perfect for learning. To use MySQL or PostgreSQL, change DB_CONNECTION and the credentials in .env and create the empty database first.
The CRUD Map
| Action | HTTP method | URL | Controller method |
|---|---|---|---|
| List | GET | /products | index |
| Create form | GET | /products/create | create |
| Store | POST | /products | store |
| Show | GET | /products/{product} | show |
| Edit form | GET | /products/{product}/edit | edit |
| Update | PUT/PATCH | /products/{product} | update |
| Delete | DELETE | /products/{product} | destroy |
Following this resource convention means one route line covers all seven actions.
Step 1: Migration and Model
php artisan make:model Product -mf
public function up(): void
{
Schema::create('products', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->decimal('price', 10, 2);
$table->unsignedInteger('stock')->default(0);
$table->text('description')->nullable();
$table->timestamps();
});
}
Migrations keep your schema in Git, so teammates and servers get the exact same structure with php artisan migrate. Prices use decimal because floats introduce rounding errors with money.
class Product extends Model
{
use HasFactory;
protected $fillable = ['name', 'price', 'stock', 'description'];
}
$fillable blocks mass assignment: without it, someone could add extra fields to a form and overwrite columns you never meant to expose.
Step 2: Controller and Routes
php artisan make:controller ProductController --resource --model=Product
php artisan make:request StoreProductRequest
// routes/web.php
use App\Http\Controllers\ProductController;
Route::resource('products', ProductController::class);
Step 3: Validation with a Form Request
public function authorize(): bool
{
return true; // replace with a real permission check later
}
public function rules(): array
{
return [
'name' => 'required|string|max:255',
'price' => 'required|numeric|min:0',
'stock' => 'required|integer|min:0',
'description' => 'nullable|string|max:2000',
];
}
The generated authorize() returns false. Forget to change it and every submit ends in a 403.
Step 4: Controller Logic
public function index(Request $request)
{
$products = Product::query()
->when($request->q, fn ($query, $q) => $query->where('name', 'like', "%{$q}%"))
->latest()
->paginate(10)
->withQueryString();
return view('products.index', compact('products'));
}
public function store(StoreProductRequest $request)
{
Product::create($request->validated());
return redirect()->route('products.index')->with('success', 'Product created.');
}
public function update(StoreProductRequest $request, Product $product)
{
$product->update($request->validated());
return redirect()->route('products.index')->with('success', 'Product updated.');
}
public function destroy(Product $product)
{
$product->delete();
return back()->with('success', 'Product deleted.');
}
The create and edit methods simply return their views. Product $product is route model binding: Laravel loads the record by ID and returns 404 automatically if it does not exist. validated() contains only fields that passed your rules, and withQueryString() keeps the search term across pages.
Step 5: Blade Views
Create and edit share one partial, products/_form.blade.php:
@csrf
<label>Name</label>
<input name="name" value="{{ old('name', $product->name ?? '') }}">
@error('name') <small>{{ $message }}</small> @enderror
<label>Price</label>
<input type="number" step="0.01" name="price" value="{{ old('price', $product->price ?? '') }}">
<label>Stock</label>
<input type="number" name="stock" value="{{ old('stock', $product->stock ?? 0) }}">
<button type="submit">Save</button>
Browsers only send GET and POST, so the edit form spoofs PUT and the delete button spoofs DELETE:
<form method="POST" action="{{ route('products.update', $product) }}">
@method('PUT')
@include('products._form')
</form>
<form method="POST" action="{{ route('products.destroy', $product) }}"
onsubmit="return confirm('Delete this product?')">
@csrf
@method('DELETE')
<button type="submit">Delete</button>
</form>
old() refills the form after a failed validation so users do not retype everything. Put {{ $products->links() }} under the list table for pagination.
Step 6: The List Page and Flash Messages
The index.blade.php view shows the session message, a search box, and the product table:
@if (session('success'))
<div class="alert">{{ session('success') }}</div>
@endif
<form method="GET" action="{{ route('products.index') }}">
<input name="q" value="{{ request('q') }}" placeholder="Search products">
</form>
<table>
@forelse ($products as $product)
<tr>
<td>{{ $product->name }}</td>
<td>${{ number_format($product->price, 2) }}</td>
<td>{{ $product->stock }}</td>
<td><a href="{{ route('products.edit', $product) }}">Edit</a></td>
</tr>
@empty
<tr><td colspan="4">No products yet.</td></tr>
@endforelse
</table>
{{ $products->links() }}
@forelse handles the empty state, so new users see a message instead of a blank page. Double curly braces escape HTML automatically, so a product name containing a script tag will not run.
Step 7: Seed Sample Data with a Factory
Pagination and search problems never show up with two rows. Fill in the factory created in step 1:
// database/factories/ProductFactory.php
public function definition(): array
{
return [
'name' => fake()->words(2, true),
'price' => fake()->randomFloat(2, 1, 250),
'stock' => fake()->numberBetween(0, 100),
];
}
Run php artisan tinker and type Product::factory()->count(50)->create();. Now you can try search and paging with realistic volume. The same factory will later power your automated tests.
Common Errors and Fixes
| Error | Cause | Fix |
|---|---|---|
| 419 Page Expired | Missing @csrf or expired session | Add @csrf to every POST form |
| MassAssignmentException | Column not in $fillable | Add it to $fillable |
| 403 This action is unauthorized | authorize() still returns false | Return true or a real check |
| 405 Method Not Allowed | Missing @method('PUT') or @method('DELETE') | Add the method directive |
| Target class does not exist | Controller not imported in routes | Add the correct use line |
| Connection refused / no such table | Wrong database settings or migrations not run | Check .env, run config:clear and migrate |
How to read an error page
When Laravel shows its error page, do not paste the whole thing into a search engine. Read the first line for the error type, then find the first stack frame that points to your own code under app/ or resources/views/. That is usually where the typo, missing import, or unpassed variable lives. The same error is also written to storage/logs/laravel.log if you closed the page.
Checklist for a Usable CRUD
- All input validated in a Form Request, including updates.
- Every form has
@csrf; edit and delete forms have@method. - Success messages after create, update, and delete.
- The list is paginated and searchable.
- Deletion asks for confirmation.
- CRUD routes sit behind the
authmiddleware before you deploy.
Where to Go Next
Once a single-table CRUD feels easy, add relationships such as products belonging to categories, and sketch them first with a simple ERD. Then review web application security best practices before real users touch the app. You will reuse these seven actions in nearly every project, from point-of-sale apps to inventory systems.