Build an Equipment Manager with Laravel
Build a full CRUD web app with Laravel 13, Eloquent ORM, and Blade views.
Introduction
30 Second Summary
Every office and lab has equipment that needs tracking. Laptops go missing, projectors break down, and nobody knows which tools are available until they physically walk around and check.
In this project, you will build an Equipment Management System using Laravel that lets staff track items, filter by status or category, and manage equipment through a full create-read-update-delete interface.
What You'll Build
A web application that manages office and lab equipment with category relationships, status tracking, and filtered views.
By the end of this project, you'll have:
- A Laravel 13 application connected to a MySQL database with two related tables (equipment and categories).
- A full CRUD interface built with a resource controller, Form Request validation, and Blade views styled with Tailwind CSS.
- Filtering capabilities using Eloquent query scopes that let users narrow equipment by status or category.
- Secret Mission: Write automated feature tests to verify your CRUD operations work correctly.
Are there any prerequisites?
You should have basic PHP knowledge and familiarity with MySQL. You do not need prior Laravel experience.
Set Up Your Laravel Development Environment
Every web application starts with a solid foundation. Before you can write any application code, you need a working development environment with the right tools connected together.
In this step, you will verify your PHP toolchain, scaffold a fresh Laravel project, and connect it to a MySQL database. By the end, you will have the Laravel welcome page loading in your browser.
In this step, get ready to:
- Verify PHP, Composer, and the Laravel installer are ready.
- Create a new Laravel project configured for MySQL.
- Create the MySQL database and confirm the connection works.
Verify your development tools
You already have PHP, Composer, and the Laravel installer on your machine. Confirm each tool is working by checking its version.
- Open a terminal in VS Code by pressing Ctrl+` (backtick key, top-left of your keyboard).
- Check your PHP version by running this command:
php --version
- You should see 8.5 (or higher) in the output.
- Check your Composer version by running:
composer --version
- You should see a version number like 2.x.x in the output.
- Check the Laravel installer by running:
laravel --version
- You should see the Laravel installer version printed.
Command not found?
If any command is not recognised, you may need to restart your terminal session. Close the terminal panel and reopen it with Ctrl+`. If that does not resolve it, reinstall the toolchain by running the official install script:
/bin/bash -c "$(curl -fsSL https://php.new/install/linux/8.5)"
After the script completes, close and reopen your terminal, then re-run the version checks above.
Help me troubleshoot my installation
Create the Laravel project
The Laravel installer scaffolds a full project structure with routing, configuration, and testing files already wired up. You will select specific options during creation to match this project's needs.
- Move to your Desktop by running this command:
cd ~/Desktop
- Create your project by running:
laravel new equipment-manager
What prompts will I see?
The installer will ask you three questions. Answer each one as follows:
- Starter kit: select No starter kit. This gives you a clean app without pre-built authentication.
- Testing framework: select PHPUnit. You will use this in the Secret Mission later.
- Database: select MySQL. This matches the database you will configure next.
- Wait for Composer to install all dependencies. This may take a minute or two.
- Once complete, move into the new project folder by running:
cd equipment-manager
- Confirm the project was created by running:
ls
- You should see folders like app, database, routes, and resources in the output.
Seeing a database connection error during setup?
The installer may try to run migrations automatically and fail if MySQL is not running yet. This is fine. You will set up the database connection in the next substep.
Help me understand this error
Create the database and start the development server
Laravel needs a MySQL database to store your application data. You will create the database, verify your connection settings, then confirm everything works by running migrations.
- Log into the MySQL command line by running:
mysql -u root -p
- Enter your MySQL root password when prompted. If you have not set a password, try pressing Enter with no input.
- Create the database by running this SQL statement:
CREATE DATABASE equipment_manager;
- You should see Query OK, 1 row affected in the output.
- Exit the MySQL shell by running:
exit;
What is a MySQL database?
A database is a named container that holds all of your application's tables, rows, and relationships. Laravel connects to this database using the credentials in your .env file.
Now verify that your project's .env file has the correct database settings.
- Open the .env file in the Explorer sidebar of VS Code. It is in the root of your equipment-manager folder.
- Find the database section and confirm these values match your MySQL setup:
DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_PORT=3306
DB_DATABASE=equipment_manager
DB_USERNAME=root
DB_PASSWORD=
What do these values mean?
- DB_CONNECTION tells Laravel which database driver to use.
- DB_HOST and DB_PORT point to your local MySQL server.
- DB_DATABASE is the name of the database you just created.
- DB_USERNAME and DB_PASSWORD are your MySQL login credentials. Update these if your MySQL user is different from root or has a password set.
- If you changed anything, save the .env file.
- Confirm the database connection works by running Laravel's migrations:
php artisan migrate
- You should see a list of migration file names with DONE next to each one. This confirms Laravel can talk to your MySQL database.
Access denied or connection refused?
If you see an access denied error, double check that DB_USERNAME and DB_PASSWORD in your .env file match the credentials you used when logging into MySQL above.
If you see a connection refused error, make sure your MySQL server is running. On Linux, start it with sudo systemctl start mysql.
Help me fix my database connection
- Start the development server by running:
composer run dev
- Open your browser and visit http://localhost:8000.
- You should see the Laravel welcome page with the Laravel logo and version number.
Page not loading?
Make sure the composer run dev command is still running in your terminal. If you closed the terminal or pressed Ctrl+C, the server stopped. Run it again.
Help me get the development server working
Your development environment is fully configured. Next up, you will design the database schema and create Eloquent models to represent your equipment and categories.
Design the Database and Create Eloquent Models
Your Laravel environment is ready and connected to MySQL. Before you can build any features, the application needs a database structure that defines what data it stores and how different pieces of data relate to each other.
In this step, you will create two database tables (categories and equipment), define Eloquent models with relationships between them, and seed the database with starter data.
In this step, get ready to:
- Generate migrations and models for categories and equipment.
- Define Eloquent relationships, fillable fields, and query scopes.
- Create a seeder and populate the database with sample categories.
Generate models with migrations and define table schemas
Laravel uses migrations to define your database tables in code. Each migration file describes one table's columns, types, and constraints. You will generate two models (Category and Equipment), each paired with its own migration file.
- Generate the Category model and its migration by running this command in your terminal:
php artisan make:model Category -m
- Generate the Equipment model and its migration by running:
php artisan make:model Equipment -m
- Confirm the files exist by running:
ls app/Models/
ls database/migrations/
You should see Category.php and Equipment.php in the Models folder, and two new migration files ending in _create_categories_table.php and _create_equipment_table.php.
Command not found?
Make sure you are inside the equipment-manager project folder. Run cd ~/Desktop/equipment-manager first, then try again.
Help me troubleshoot
Now you will define the columns for the categories table. This table stores equipment categories like "Electronics" or "Furniture".
- In VS Code, open the migration file ending in _create_categories_table.php inside the database/migrations folder.
- Replace the entire file contents with this code:
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('categories', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->string('description')->nullable();
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('categories');
}
};
What does this migration do?
- id() creates an auto-incrementing primary key column.
- string('name') stores the category name (required).
- string('description')->nullable() stores an optional description.
- timestamps() adds created_at and updated_at columns automatically.
- Save the file.
Next, define the equipment table. This table is more complex because it references the categories table and uses an enum for status values.
- Open the migration file ending in _create_equipment_table.php inside database/migrations.
- Replace the entire file contents with this code:
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('equipment', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->text('description')->nullable();
$table->string('serial_number')->unique();
$table->enum('status', ['available', 'in-use', 'maintenance'])->default('available');
$table->foreignId('category_id')->constrained()->onDelete('cascade');
$table->date('purchase_date')->nullable();
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('equipment');
}
};
What does this migration do?
- string('serial_number')->unique() enforces that no two items share the same serial number.
- enum('status', [...]) restricts the status column to exactly three allowed values: available, in-use, or maintenance.
- foreignId('category_id')->constrained() creates a foreign key linking each piece of equipment to a category.
- onDelete('cascade') means if a category is deleted, all equipment in that category is also deleted.
- Save the file.
✔️ Awesome, I've got everything!
Great. Make sure both migration files are saved before moving on.
ⓧ I'd like to double check the full code
Here is the categories migration:
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('categories', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->string('description')->nullable();
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('categories');
}
};
And here is the equipment migration:
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('equipment', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->text('description')->nullable();
$table->string('serial_number')->unique();
$table->enum('status', ['available', 'in-use', 'maintenance'])->default('available');
$table->foreignId('category_id')->constrained()->onDelete('cascade');
$table->date('purchase_date')->nullable();
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('equipment');
}
};
Define Eloquent relationships and query scopes
Eloquent models are PHP classes that represent your database tables. They define how tables relate to each other and provide reusable methods for querying data.
- Open app/Models/Category.php in VS Code.
- Replace the entire file contents with this code:
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
class Category extends Model
{
use HasFactory;
// Allow mass assignment for these columns
protected $fillable = ['name', 'description'];
// One category has many equipment items
public function equipment(): HasMany
{
return $this->hasMany(Equipment::class);
}
}
What does this model do?
- $fillable lists the columns that can be set via mass assignment (e.g. when calling Category::create([...])).
- The equipment() method defines a one-to-many relationship. One category can have many pieces of equipment.
- HasFactory enables model factories for testing (you will use this in the Secret Mission).
- Save Category.php.
Now define the Equipment model with the inverse relationship and two query scopes for filtering.
- Open app/Models/Equipment.php in VS Code.
- Replace the entire file contents with this code:
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class Equipment extends Model
{
use HasFactory;
// Allow mass assignment for these columns
protected $fillable = [
'name',
'description',
'serial_number',
'status',
'category_id',
'purchase_date',
];
// Each equipment item belongs to one category
public function category(): BelongsTo
{
return $this->belongsTo(Category::class);
}
// Query scope: filter equipment by status
public function scopeStatus(Builder $query, string $status): Builder
{
return $query->where('status', $status);
}
// Query scope: filter equipment by category
public function scopeInCategory(Builder $query, int $categoryId): Builder
{
return $query->where('category_id', $categoryId);
}
}
What are query scopes?
Query scopes are reusable filters you define once on a model and call anywhere. Instead of writing ->where('status', 'available') every time you need filtered results, you call Equipment::status('available').
Laravel recognizes any method starting with scope as a query scope. You call it without the scope prefix (e.g. scopeStatus becomes ->status()).
- Save Equipment.php.
✔️ Awesome, I've got everything!
Great. Make sure both model files are saved before moving on.
ⓧ I'd like to double check the full code
Here is the Category model:
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
class Category extends Model
{
use HasFactory;
protected $fillable = ['name', 'description'];
public function equipment(): HasMany
{
return $this->hasMany(Equipment::class);
}
}
And here is the Equipment model:
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class Equipment extends Model
{
use HasFactory;
protected $fillable = [
'name',
'description',
'serial_number',
'status',
'category_id',
'purchase_date',
];
public function category(): BelongsTo
{
return $this->belongsTo(Category::class);
}
public function scopeStatus(Builder $query, string $status): Builder
{
return $query->where('status', $status);
}
public function scopeInCategory(Builder $query, int $categoryId): Builder
{
return $query->where('category_id', $categoryId);
}
}
Create a seeder and populate the database
Seeders let you populate your database with initial data. You will create a seeder that inserts five equipment categories so the app has useful options from the start.
- Generate the CategorySeeder file by running:
php artisan make:seeder CategorySeeder
- Open database/seeders/CategorySeeder.php in VS Code.
- Replace the entire file contents with this code:
<?php
namespace Database\Seeders;
use App\Models\Category;
use Illuminate\Database\Seeder;
class CategorySeeder extends Seeder
{
public function run(): void
{
// Define the starter categories for the equipment system
$categories = [
['name' => 'Electronics', 'description' => 'Laptops, monitors, projectors, and other electronic devices'],
['name' => 'Furniture', 'description' => 'Desks, chairs, shelves, and office furniture'],
['name' => 'Lab Equipment', 'description' => 'Microscopes, oscilloscopes, and laboratory instruments'],
['name' => 'Office Supplies', 'description' => 'Printers, scanners, and general office equipment'],
['name' => 'Tools', 'description' => 'Hand tools, power tools, and maintenance equipment'],
];
// Insert each category into the database
foreach ($categories as $category) {
Category::create($category);
}
}
}
What does this seeder do?
The seeder defines an array of five categories with names and descriptions. It loops through each one and calls Category::create() to insert it into the database. This gives your app meaningful dropdown options from the very first run.
- Save CategorySeeder.php.
Now register the seeder in the main DatabaseSeeder so Laravel knows to run it.
- Open database/seeders/DatabaseSeeder.php in VS Code.
- Replace the entire file contents with this code:
<?php
namespace Database\Seeders;
use Illuminate\Database\Seeder;
class DatabaseSeeder extends Seeder
{
public function run(): void
{
// Run the category seeder to populate initial data
$this->call([
CategorySeeder::class,
]);
}
}
What does this change?
The DatabaseSeeder is the entry point Laravel calls when you run seeders. By registering CategorySeeder::class inside the call() array, Laravel will execute your category seeder automatically.
- Save DatabaseSeeder.php.
✔️ Awesome, I've got everything!
Great. Make sure both seeder files are saved.
ⓧ I'd like to double check the full code
Here is the CategorySeeder:
<?php
namespace Database\Seeders;
use App\Models\Category;
use Illuminate\Database\Seeder;
class CategorySeeder extends Seeder
{
public function run(): void
{
$categories = [
['name' => 'Electronics', 'description' => 'Laptops, monitors, projectors, and other electronic devices'],
['name' => 'Furniture', 'description' => 'Desks, chairs, shelves, and office furniture'],
['name' => 'Lab Equipment', 'description' => 'Microscopes, oscilloscopes, and laboratory instruments'],
['name' => 'Office Supplies', 'description' => 'Printers, scanners, and general office equipment'],
['name' => 'Tools', 'description' => 'Hand tools, power tools, and maintenance equipment'],
];
foreach ($categories as $category) {
Category::create($category);
}
}
}
And here is the DatabaseSeeder:
<?php
namespace Database\Seeders;
use Illuminate\Database\Seeder;
class DatabaseSeeder extends Seeder
{
public function run(): void
{
$this->call([
CategorySeeder::class,
]);
}
}
Now run the migrations and seeders together to build your database tables and populate them with data.
- Run the migrations and seed the database by running:
php artisan migrate --seed
You should see output confirming each migration ran and the seeder executed successfully.
Seeing a connection refused error?
Make sure your MySQL server is running. Check that the DB_DATABASE, DB_USERNAME, and DB_PASSWORD values in your .env file match your MySQL credentials exactly.
If you already ran migrations earlier and see a "table already exists" error, run php artisan migrate:fresh --seed to drop all tables and rebuild from scratch.
Help me fix my database connection
- Verify the categories were seeded by opening Tinker:
php artisan tinker
- Inside the Tinker prompt, run this command:
App\Models\Category::count()
You should see the number 5 returned, confirming all five categories exist in the database.
- Type exit to leave Tinker.
What is Tinker?
Tinker is Laravel's interactive REPL (read-eval-print loop). It lets you run any PHP or Eloquent code directly against your application without writing a route or controller. It is useful for quick database checks during development.
Your database is structured and seeded with starter data. Next up, you will build the controller that handles creating, reading, updating, and deleting equipment records.
Build the Equipment Resource Controller
Your database is structured and your models know how to talk to each other. Now you need something to handle the actual user interactions.
In this step, you will create a resource controller that manages all equipment CRUD operations, plus dedicated Form Request classes that validate user input before it ever reaches your controller logic.
In this step, get ready to:
- Generate the resource controller and Form Request classes.
- Implement all seven CRUD methods in the controller.
- Register the resource route and verify it works.
Generate the controller and Form Request classes
Laravel separates validation logic into dedicated Form Request classes. This keeps your controller methods focused on business logic rather than checking whether every field is valid.
- Generate the resource controller and both Form Request classes by running these commands:
php artisan make:controller EquipmentController --resource
php artisan make:request StoreEquipmentRequest
php artisan make:request UpdateEquipmentRequest
What do these commands do?
The --resource flag generates a controller with all seven CRUD method stubs (index, create, store, show, edit, update, destroy) already in place.
The make:request commands create Form Request classes in app/Http/Requests/. Each class has an authorize() method for access control and a rules() method for validation rules.
- Open app/Http/Requests/StoreEquipmentRequest.php in VS Code and replace its contents with the following validation rules:
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class StoreEquipmentRequest extends FormRequest
{
public function authorize(): bool
{
// Allow all users to submit this form
return true;
}
public function rules(): array
{
return [
'name' => 'required|string|max:255',
'description' => 'nullable|string',
'serial_number' => 'required|string|max:255|unique:equipment,serial_number',
'status' => 'required|in:available,in-use,maintenance',
'category_id' => 'required|exists:categories,id',
'purchase_date' => 'nullable|date',
];
}
}
What do these validation rules do?
- required|string|max:255 ensures the field is present, is a string, and does not exceed 255 characters.
- unique:equipment,serial_number prevents duplicate serial numbers across all equipment records.
- in:available,in-use,maintenance restricts the status field to one of the three allowed enum values.
- exists:categories,id verifies that the selected category actually exists in the database.
- Save StoreEquipmentRequest.php.
Seeing a namespace error?
Make sure the namespace at the top of the file reads App\Http\Requests exactly. If you accidentally renamed the file or folder, Laravel cannot autoload the class.
Help me fix my StoreEquipmentRequest namespace error.
- Open app/Http/Requests/UpdateEquipmentRequest.php and replace its contents with this code:
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class UpdateEquipmentRequest extends FormRequest
{
public function authorize(): bool
{
// Allow all users to submit this form
return true;
}
public function rules(): array
{
return [
'name' => 'required|string|max:255',
'description' => 'nullable|string',
'serial_number' => [
'required',
'string',
'max:255',
// Ignore the current record so it can keep its own serial number
Rule::unique('equipment')->ignore($this->route('equipment')),
],
'status' => 'required|in:available,in-use,maintenance',
'category_id' => 'required|exists:categories,id',
'purchase_date' => 'nullable|date',
];
}
}
Why is the serial number rule different here?
When updating an existing equipment record, the serial number might not have changed. Without Rule::unique('equipment')->ignore(...), Laravel would reject the update because that serial number already exists in the database. The ignore() call tells Laravel to skip the current record when checking uniqueness.
- Save UpdateEquipmentRequest.php.
✔️ Awesome, I've got everything!
Great. Make sure both files are saved before moving on.
ⓧ I'd like to double check the full code
Here is the full StoreEquipmentRequest.php:
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class StoreEquipmentRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'name' => 'required|string|max:255',
'description' => 'nullable|string',
'serial_number' => 'required|string|max:255|unique:equipment,serial_number',
'status' => 'required|in:available,in-use,maintenance',
'category_id' => 'required|exists:categories,id',
'purchase_date' => 'nullable|date',
];
}
}
And here is the full UpdateEquipmentRequest.php:
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class UpdateEquipmentRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'name' => 'required|string|max:255',
'description' => 'nullable|string',
'serial_number' => [
'required',
'string',
'max:255',
Rule::unique('equipment')->ignore($this->route('equipment')),
],
'status' => 'required|in:available,in-use,maintenance',
'category_id' => 'required|exists:categories,id',
'purchase_date' => 'nullable|date',
];
}
}
Implement the CRUD methods in EquipmentController
A resource controller maps each standard CRUD action to its own method. Laravel automatically routes HTTP verbs (GET, POST, PUT, DELETE) to the correct method based on how you register the route.
- Open app/Http/Controllers/EquipmentController.php and replace the entire file with this code:
<?php
namespace App\Http\Controllers;
use App\Http\Requests\StoreEquipmentRequest;
use App\Http\Requests\UpdateEquipmentRequest;
use App\Models\Category;
use App\Models\Equipment;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class EquipmentController extends Controller
{
public function index(Request $request): View
{
// Start query with eager-loaded category relationship
$query = Equipment::with('category');
// Apply status filter if present in the request
if ($request->filled('status')) {
$query->status($request->status);
}
// Apply category filter if present in the request
if ($request->filled('category_id')) {
$query->inCategory($request->category_id);
}
// Get paginated results ordered by newest first
$equipment = $query->latest()->paginate(10);
$categories = Category::all();
return view('equipment.index', [
'equipment' => $equipment,
'categories' => $categories,
'selectedStatus' => $request->status,
'selectedCategory' => $request->category_id,
]);
}
public function create(): View
{
// Pass all categories so the form can populate a dropdown
$categories = Category::all();
return view('equipment.create', compact('categories'));
}
public function store(StoreEquipmentRequest $request): RedirectResponse
{
// Create equipment using only validated data
Equipment::create($request->validated());
return redirect()->route('equipment.index')->with('success', 'Equipment added successfully.');
}
public function show(Equipment $equipment): View
{
// Laravel auto-resolves the model via route model binding
return view('equipment.show', compact('equipment'));
}
public function edit(Equipment $equipment): View
{
// Pass both the equipment and categories for the edit form
$categories = Category::all();
return view('equipment.edit', compact('equipment', 'categories'));
}
public function update(UpdateEquipmentRequest $request, Equipment $equipment): RedirectResponse
{
// Update with validated data only
$equipment->update($request->validated());
return redirect()->route('equipment.index')->with('success', 'Equipment updated successfully.');
}
public function destroy(Equipment $equipment): RedirectResponse
{
// Delete the record and redirect with a success message
$equipment->delete();
return redirect()->route('equipment.index')->with('success', 'Equipment deleted successfully.');
}
}
What does this controller do?
- index() lists all equipment with eager-loaded categories. It conditionally applies the scopeStatus and scopeInCategory query scopes you defined earlier, then paginates results 10 per page.
- store() and update() type-hint the Form Request classes. Laravel runs validation automatically before these methods execute. Only validated data reaches $request->validated().
- show(), edit(), update(), and destroy() all accept an Equipment $equipment parameter. Laravel's route model binding automatically fetches the correct record from the database based on the URL.
- Save EquipmentController.php.
Seeing a class not found error?
Verify that the use statements at the top of the file match the namespaces of your Form Request and Model files exactly. A typo in App\Http\Requests\StoreEquipmentRequest or App\Models\Equipment will cause this.
Help me debug a class not found error in my EquipmentController.
✔️ Awesome, I've got everything!
Great. Double check you have saved EquipmentController.php before continuing.
ⓧ I'd like to double check the full code
<?php
namespace App\Http\Controllers;
use App\Http\Requests\StoreEquipmentRequest;
use App\Http\Requests\UpdateEquipmentRequest;
use App\Models\Category;
use App\Models\Equipment;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class EquipmentController extends Controller
{
public function index(Request $request): View
{
$query = Equipment::with('category');
if ($request->filled('status')) {
$query->status($request->status);
}
if ($request->filled('category_id')) {
$query->inCategory($request->category_id);
}
$equipment = $query->latest()->paginate(10);
$categories = Category::all();
return view('equipment.index', [
'equipment' => $equipment,
'categories' => $categories,
'selectedStatus' => $request->status,
'selectedCategory' => $request->category_id,
]);
}
public function create(): View
{
$categories = Category::all();
return view('equipment.create', compact('categories'));
}
public function store(StoreEquipmentRequest $request): RedirectResponse
{
Equipment::create($request->validated());
return redirect()->route('equipment.index')->with('success', 'Equipment added successfully.');
}
public function show(Equipment $equipment): View
{
return view('equipment.show', compact('equipment'));
}
public function edit(Equipment $equipment): View
{
$categories = Category::all();
return view('equipment.edit', compact('equipment', 'categories'));
}
public function update(UpdateEquipmentRequest $request, Equipment $equipment): RedirectResponse
{
$equipment->update($request->validated());
return redirect()->route('equipment.index')->with('success', 'Equipment updated successfully.');
}
public function destroy(Equipment $equipment): RedirectResponse
{
$equipment->delete();
return redirect()->route('equipment.index')->with('success', 'Equipment deleted successfully.');
}
}
Register the resource route and verify
A single Route::resource() call tells Laravel to wire up all seven RESTful routes to your controller methods automatically. You do not need to define each route individually.
- Open routes/web.php and replace its entire contents with:
<?php
use App\Http\Controllers\EquipmentController;
use Illuminate\Support\Facades\Route;
// Redirect the homepage to the equipment index
Route::get('/', function () {
return redirect()->route('equipment.index');
});
// Register all 7 CRUD routes for equipment
Route::resource('equipment', EquipmentController::class);
What does this route file do?
The root URL (/) now redirects visitors straight to the equipment listing page. The Route::resource line registers GET, POST, PUT, and DELETE routes for all seven controller methods with a single declaration.
- Save routes/web.php.
- Verify that all routes are registered by running:
php artisan route:list
You should see a table listing routes for equipment.index, equipment.create, equipment.store, equipment.show, equipment.edit, equipment.update, and equipment.destroy. All seven routes should appear with their HTTP verbs and URIs.
Not seeing all 7 routes?
Check that routes/web.php has the use App\Http\Controllers\EquipmentController; import at the top. Without it, Laravel cannot find the controller class.
Help me figure out why my resource routes are missing.
✔️ Awesome, I've got everything!
Great. All seven resource routes are registered and your controller is ready to handle requests.
ⓧ I'd like to double check the full code
<?php
use App\Http\Controllers\EquipmentController;
use Illuminate\Support\Facades\Route;
Route::get('/', function () {
return redirect()->route('equipment.index');
});
Route::resource('equipment', EquipmentController::class);
Your controller, validation, and routing layer are all wired up. Next, you will build the Blade views that give users an actual interface to interact with.
Create Blade Views for the UI
Your controller, models, and routes are all wired up. The backend knows how to handle every CRUD operation. But right now, visiting http://localhost:8000/equipment throws an error because the views don't exist yet.
In this step, you'll build the visual layer using Blade templates styled with Tailwind CSS via CDN. You'll create a shared layout, an equipment listing with filters, and forms for adding, editing, and viewing items.
In this step, get ready to:
- Create a shared layout template with navigation and flash messages.
- Build the equipment index view with a filterable table.
- Create form views for adding, editing, and viewing equipment.
Create the layout template
Blade uses template inheritance so you define a shared layout once and every page extends it. This keeps your navigation, CSS imports, and flash messages in a single file instead of repeating them across every view.
- Create the required directories by running these commands in your terminal:
mkdir -p resources/views/layouts resources/views/equipment
- Confirm the directories exist by running:
ls resources/views
You should see equipment and layouts listed alongside the existing welcome.blade.php file.
- In VS Code, create a new file at resources/views/layouts/app.blade.php and paste the following code:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Equipment Manager - @yield('title')</title>
<script src="https://cdn.tailwindcss.com"></script>
</head>
<body class="bg-gray-100 min-h-screen">
<nav class="bg-white shadow-sm border-b">
<div class="max-w-7xl mx-auto px-4 py-4">
<div class="flex justify-between items-center">
<a href="{{ route('equipment.index') }}" class="text-xl font-bold text-gray-800">Equipment Manager</a>
<a href="{{ route('equipment.create') }}" class="bg-blue-600 text-white px-4 py-2 rounded-md hover:bg-blue-700">Add Equipment</a>
</div>
</div>
</nav>
<main class="max-w-7xl mx-auto px-4 py-8">
@if(session('success'))
<div class="bg-green-100 border border-green-400 text-green-700 px-4 py-3 rounded mb-6">
{{ session('success') }}
</div>
@endif
@yield('content')
</main>
</body>
</html>
What does this layout do?
- The Tailwind CSS CDN script tag loads utility classes so you can style elements directly in HTML without writing custom CSS.
- @yield('title') and @yield('content') are placeholders that child views fill in with their own content.
- The session('success') block displays green confirmation banners after successful create, update, or delete operations.
- The navigation bar provides a consistent header with a link back to the equipment list and a button to add new items.
- Save app.blade.php.
Seeing a blank page or error?
- Make sure the file is saved at exactly resources/views/layouts/app.blade.php. A misplaced folder level causes Blade to not find the layout.
- Check that the <script src="https://cdn.tailwindcss.com"></script> tag is inside the <head> section.
Help me troubleshoot my Blade layout file not loading.
✔️ Awesome, I've got everything!
Great. Make sure you've saved the file before moving on.
ⓧ I'd like to double check the full code
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Equipment Manager - @yield('title')</title>
<script src="https://cdn.tailwindcss.com"></script>
</head>
<body class="bg-gray-100 min-h-screen">
<nav class="bg-white shadow-sm border-b">
<div class="max-w-7xl mx-auto px-4 py-4">
<div class="flex justify-between items-center">
<a href="{{ route('equipment.index') }}" class="text-xl font-bold text-gray-800">Equipment Manager</a>
<a href="{{ route('equipment.create') }}" class="bg-blue-600 text-white px-4 py-2 rounded-md hover:bg-blue-700">Add Equipment</a>
</div>
</div>
</nav>
<main class="max-w-7xl mx-auto px-4 py-8">
@if(session('success'))
<div class="bg-green-100 border border-green-400 text-green-700 px-4 py-3 rounded mb-6">
{{ session('success') }}
</div>
@endif
@yield('content')
</main>
</body>
</html>
Build the equipment index view
The index view is the main page users see when they open the application. It shows a table of all equipment with filter dropdowns and action links for each row.
- Create a new file at resources/views/equipment/index.blade.php and paste the following code:
@extends('layouts.app')
@section('title', 'All Equipment')
@section('content')
<div class="mb-6">
<h1 class="text-2xl font-bold text-gray-800 mb-4">Equipment Inventory</h1>
<form method="GET" action="{{ route('equipment.index') }}" class="flex gap-4 items-end">
<div>
<label for="status" class="block text-sm font-medium text-gray-700 mb-1">Status</label>
<select name="status" id="status" class="border border-gray-300 rounded-md px-3 py-2">
<option value="">All Statuses</option>
<option value="available" @selected($selectedStatus === 'available')>Available</option>
<option value="in-use" @selected($selectedStatus === 'in-use')>In Use</option>
<option value="maintenance" @selected($selectedStatus === 'maintenance')>Maintenance</option>
</select>
</div>
<div>
<label for="category_id" class="block text-sm font-medium text-gray-700 mb-1">Category</label>
<select name="category_id" id="category_id" class="border border-gray-300 rounded-md px-3 py-2">
<option value="">All Categories</option>
@foreach($categories as $category)
<option value="{{ $category->id }}" @selected($selectedCategory == $category->id)>{{ $category->name }}</option>
@endforeach
</select>
</div>
<button type="submit" class="bg-gray-800 text-white px-4 py-2 rounded-md hover:bg-gray-900">Filter</button>
<a href="{{ route('equipment.index') }}" class="text-gray-600 hover:text-gray-800 px-4 py-2">Clear</a>
</form>
</div>
<div class="bg-white rounded-lg shadow overflow-hidden">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Name</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Serial Number</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Category</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Status</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Purchase Date</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Actions</th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200">
@forelse($equipment as $item)
<tr>
<td class="px-6 py-4 whitespace-nowrap font-medium text-gray-900">{{ $item->name }}</td>
<td class="px-6 py-4 whitespace-nowrap text-gray-600">{{ $item->serial_number }}</td>
<td class="px-6 py-4 whitespace-nowrap text-gray-600">{{ $item->category->name }}</td>
<td class="px-6 py-4 whitespace-nowrap">
@if($item->status === 'available')
<span class="px-2 py-1 text-xs font-semibold rounded-full bg-green-100 text-green-800">Available</span>
@elseif($item->status === 'in-use')
<span class="px-2 py-1 text-xs font-semibold rounded-full bg-yellow-100 text-yellow-800">In Use</span>
@else
<span class="px-2 py-1 text-xs font-semibold rounded-full bg-red-100 text-red-800">Maintenance</span>
@endif
</td>
<td class="px-6 py-4 whitespace-nowrap text-gray-600">{{ $item->purchase_date ?? 'N/A' }}</td>
<td class="px-6 py-4 whitespace-nowrap text-sm">
<a href="{{ route('equipment.show', $item) }}" class="text-blue-600 hover:text-blue-800 mr-3">View</a>
<a href="{{ route('equipment.edit', $item) }}" class="text-indigo-600 hover:text-indigo-800 mr-3">Edit</a>
<form action="{{ route('equipment.destroy', $item) }}" method="POST" class="inline" onsubmit="return confirm('Are you sure?')">
@csrf
@method('DELETE')
<button type="submit" class="text-red-600 hover:text-red-800">Delete</button>
</form>
</td>
</tr>
@empty
<tr>
<td colspan="6" class="px-6 py-4 text-center text-gray-500">No equipment found. Add your first item!</td>
</tr>
@endforelse
</tbody>
</table>
</div>
<div class="mt-4">
{{ $equipment->withQueryString()->links() }}
</div>
@endsection
What does this view do?
- @extends('layouts.app') tells Blade this view inherits from your layout template.
- The filter form uses GET method so selections appear in the URL as query parameters. This lets the controller read them and apply query scopes.
- @forelse loops through equipment items when they exist. The @empty block shows a friendly message when the table has no data.
- Status badges use conditional classes (green for available, yellow for in-use, red for maintenance) so users can spot item states at a glance.
- The withQueryString() method on pagination links preserves the active filters when navigating between pages.
- Save index.blade.php.
- In your browser, visit http://localhost:8000/equipment. You should see the "Equipment Inventory" heading with Status and Category filter dropdowns, and an empty table showing "No equipment found. Add your first item!".
Page not loading or showing an error?
- If you see a "View not found" error, check the file is at resources/views/equipment/index.blade.php (not resources/views/index.blade.php).
- If you see an "Undefined variable" error, make sure your EquipmentController index method passes selectedStatus and selectedCategory to the view.
- If styles look broken (no colors, no spacing), verify your layout file includes the Tailwind CDN script tag in the head section.
Help me debug my equipment index Blade view.
✔️ Awesome, I've got everything!
Great. The index page is working. Time to build the forms.
ⓧ I'd like to double check the full code
@extends('layouts.app')
@section('title', 'All Equipment')
@section('content')
<div class="mb-6">
<h1 class="text-2xl font-bold text-gray-800 mb-4">Equipment Inventory</h1>
<form method="GET" action="{{ route('equipment.index') }}" class="flex gap-4 items-end">
<div>
<label for="status" class="block text-sm font-medium text-gray-700 mb-1">Status</label>
<select name="status" id="status" class="border border-gray-300 rounded-md px-3 py-2">
<option value="">All Statuses</option>
<option value="available" @selected($selectedStatus === 'available')>Available</option>
<option value="in-use" @selected($selectedStatus === 'in-use')>In Use</option>
<option value="maintenance" @selected($selectedStatus === 'maintenance')>Maintenance</option>
</select>
</div>
<div>
<label for="category_id" class="block text-sm font-medium text-gray-700 mb-1">Category</label>
<select name="category_id" id="category_id" class="border border-gray-300 rounded-md px-3 py-2">
<option value="">All Categories</option>
@foreach($categories as $category)
<option value="{{ $category->id }}" @selected($selectedCategory == $category->id)>{{ $category->name }}</option>
@endforeach
</select>
</div>
<button type="submit" class="bg-gray-800 text-white px-4 py-2 rounded-md hover:bg-gray-900">Filter</button>
<a href="{{ route('equipment.index') }}" class="text-gray-600 hover:text-gray-800 px-4 py-2">Clear</a>
</form>
</div>
<div class="bg-white rounded-lg shadow overflow-hidden">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Name</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Serial Number</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Category</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Status</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Purchase Date</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Actions</th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200">
@forelse($equipment as $item)
<tr>
<td class="px-6 py-4 whitespace-nowrap font-medium text-gray-900">{{ $item->name }}</td>
<td class="px-6 py-4 whitespace-nowrap text-gray-600">{{ $item->serial_number }}</td>
<td class="px-6 py-4 whitespace-nowrap text-gray-600">{{ $item->category->name }}</td>
<td class="px-6 py-4 whitespace-nowrap">
@if($item->status === 'available')
<span class="px-2 py-1 text-xs font-semibold rounded-full bg-green-100 text-green-800">Available</span>
@elseif($item->status === 'in-use')
<span class="px-2 py-1 text-xs font-semibold rounded-full bg-yellow-100 text-yellow-800">In Use</span>
@else
<span class="px-2 py-1 text-xs font-semibold rounded-full bg-red-100 text-red-800">Maintenance</span>
@endif
</td>
<td class="px-6 py-4 whitespace-nowrap text-gray-600">{{ $item->purchase_date ?? 'N/A' }}</td>
<td class="px-6 py-4 whitespace-nowrap text-sm">
<a href="{{ route('equipment.show', $item) }}" class="text-blue-600 hover:text-blue-800 mr-3">View</a>
<a href="{{ route('equipment.edit', $item) }}" class="text-indigo-600 hover:text-indigo-800 mr-3">Edit</a>
<form action="{{ route('equipment.destroy', $item) }}" method="POST" class="inline" onsubmit="return confirm('Are you sure?')">
@csrf
@method('DELETE')
<button type="submit" class="text-red-600 hover:text-red-800">Delete</button>
</form>
</td>
</tr>
@empty
<tr>
<td colspan="6" class="px-6 py-4 text-center text-gray-500">No equipment found. Add your first item!</td>
</tr>
@endforelse
</tbody>
</table>
</div>
<div class="mt-4">
{{ $equipment->withQueryString()->links() }}
</div>
@endsection
Create the form and detail views
You need three more views: a create form for adding new equipment, an edit form for updating existing items, and a show page for viewing full details.
- Create a new file at resources/views/equipment/create.blade.php and paste the following code:
@extends('layouts.app')
@section('title', 'Add Equipment')
@section('content')
<div class="max-w-2xl mx-auto">
<h1 class="text-2xl font-bold text-gray-800 mb-6">Add New Equipment</h1>
<form method="POST" action="{{ route('equipment.store') }}" class="bg-white rounded-lg shadow p-6 space-y-4">
@csrf
<div>
<label for="name" class="block text-sm font-medium text-gray-700 mb-1">Name</label>
<input type="text" name="name" id="name" value="{{ old('name') }}" required
class="w-full border border-gray-300 rounded-md px-3 py-2 @error('name') border-red-500 @enderror">
@error('name')
<p class="text-red-500 text-sm mt-1">{{ $message }}</p>
@enderror
</div>
<div>
<label for="serial_number" class="block text-sm font-medium text-gray-700 mb-1">Serial Number</label>
<input type="text" name="serial_number" id="serial_number" value="{{ old('serial_number') }}" required
class="w-full border border-gray-300 rounded-md px-3 py-2 @error('serial_number') border-red-500 @enderror">
@error('serial_number')
<p class="text-red-500 text-sm mt-1">{{ $message }}</p>
@enderror
</div>
<div>
<label for="category_id" class="block text-sm font-medium text-gray-700 mb-1">Category</label>
<select name="category_id" id="category_id" required
class="w-full border border-gray-300 rounded-md px-3 py-2 @error('category_id') border-red-500 @enderror">
<option value="">Select a category</option>
@foreach($categories as $category)
<option value="{{ $category->id }}" @selected(old('category_id') == $category->id)>{{ $category->name }}</option>
@endforeach
</select>
@error('category_id')
<p class="text-red-500 text-sm mt-1">{{ $message }}</p>
@enderror
</div>
<div>
<label for="status" class="block text-sm font-medium text-gray-700 mb-1">Status</label>
<select name="status" id="status" required
class="w-full border border-gray-300 rounded-md px-3 py-2 @error('status') border-red-500 @enderror">
<option value="available" @selected(old('status') === 'available')>Available</option>
<option value="in-use" @selected(old('status') === 'in-use')>In Use</option>
<option value="maintenance" @selected(old('status') === 'maintenance')>Maintenance</option>
</select>
@error('status')
<p class="text-red-500 text-sm mt-1">{{ $message }}</p>
@enderror
</div>
<div>
<label for="purchase_date" class="block text-sm font-medium text-gray-700 mb-1">Purchase Date</label>
<input type="date" name="purchase_date" id="purchase_date" value="{{ old('purchase_date') }}"
class="w-full border border-gray-300 rounded-md px-3 py-2 @error('purchase_date') border-red-500 @enderror">
@error('purchase_date')
<p class="text-red-500 text-sm mt-1">{{ $message }}</p>
@enderror
</div>
<div>
<label for="description" class="block text-sm font-medium text-gray-700 mb-1">Description</label>
<textarea name="description" id="description" rows="3"
class="w-full border border-gray-300 rounded-md px-3 py-2 @error('description') border-red-500 @enderror">{{ old('description') }}</textarea>
@error('description')
<p class="text-red-500 text-sm mt-1">{{ $message }}</p>
@enderror
</div>
<div class="flex gap-4 pt-4">
<button type="submit" class="bg-blue-600 text-white px-6 py-2 rounded-md hover:bg-blue-700">Save Equipment</button>
<a href="{{ route('equipment.index') }}" class="bg-gray-200 text-gray-700 px-6 py-2 rounded-md hover:bg-gray-300">Cancel</a>
</div>
</form>
</div>
@endsection
What does this form do?
- @csrf generates a hidden token field that Laravel uses to protect against cross-site request forgery attacks. Every POST form needs it.
- old('field') repopulates form inputs with the previously submitted values if validation fails. This way, users don't lose what they typed.
- The @error directive shows validation error messages below each field in red text when the Form Request rejects the input.
- Save create.blade.php.
- In your browser, click the Add Equipment button in the navigation bar. You should see the "Add New Equipment" form with fields for Name, Serial Number, Category, Status, Purchase Date, and Description.
- Next, create resources/views/equipment/edit.blade.php and paste this code:
@extends('layouts.app')
@section('title', 'Edit Equipment')
@section('content')
<div class="max-w-2xl mx-auto">
<h1 class="text-2xl font-bold text-gray-800 mb-6">Edit: {{ $equipment->name }}</h1>
<form method="POST" action="{{ route('equipment.update', $equipment) }}" class="bg-white rounded-lg shadow p-6 space-y-4">
@csrf
@method('PUT')
<div>
<label for="name" class="block text-sm font-medium text-gray-700 mb-1">Name</label>
<input type="text" name="name" id="name" value="{{ old('name', $equipment->name) }}" required
class="w-full border border-gray-300 rounded-md px-3 py-2 @error('name') border-red-500 @enderror">
@error('name')
<p class="text-red-500 text-sm mt-1">{{ $message }}</p>
@enderror
</div>
<div>
<label for="serial_number" class="block text-sm font-medium text-gray-700 mb-1">Serial Number</label>
<input type="text" name="serial_number" id="serial_number" value="{{ old('serial_number', $equipment->serial_number) }}" required
class="w-full border border-gray-300 rounded-md px-3 py-2 @error('serial_number') border-red-500 @enderror">
@error('serial_number')
<p class="text-red-500 text-sm mt-1">{{ $message }}</p>
@enderror
</div>
<div>
<label for="category_id" class="block text-sm font-medium text-gray-700 mb-1">Category</label>
<select name="category_id" id="category_id" required
class="w-full border border-gray-300 rounded-md px-3 py-2 @error('category_id') border-red-500 @enderror">
<option value="">Select a category</option>
@foreach($categories as $category)
<option value="{{ $category->id }}" @selected(old('category_id', $equipment->category_id) == $category->id)>{{ $category->name }}</option>
@endforeach
</select>
@error('category_id')
<p class="text-red-500 text-sm mt-1">{{ $message }}</p>
@enderror
</div>
<div>
<label for="status" class="block text-sm font-medium text-gray-700 mb-1">Status</label>
<select name="status" id="status" required
class="w-full border border-gray-300 rounded-md px-3 py-2 @error('status') border-red-500 @enderror">
<option value="available" @selected(old('status', $equipment->status) === 'available')>Available</option>
<option value="in-use" @selected(old('status', $equipment->status) === 'in-use')>In Use</option>
<option value="maintenance" @selected(old('status', $equipment->status) === 'maintenance')>Maintenance</option>
</select>
@error('status')
<p class="text-red-500 text-sm mt-1">{{ $message }}</p>
@enderror
</div>
<div>
<label for="purchase_date" class="block text-sm font-medium text-gray-700 mb-1">Purchase Date</label>
<input type="date" name="purchase_date" id="purchase_date" value="{{ old('purchase_date', $equipment->purchase_date) }}"
class="w-full border border-gray-300 rounded-md px-3 py-2 @error('purchase_date') border-red-500 @enderror">
@error('purchase_date')
<p class="text-red-500 text-sm mt-1">{{ $message }}</p>
@enderror
</div>
<div>
<label for="description" class="block text-sm font-medium text-gray-700 mb-1">Description</label>
<textarea name="description" id="description" rows="3"
class="w-full border border-gray-300 rounded-md px-3 py-2 @error('description') border-red-500 @enderror">{{ old('description', $equipment->description) }}</textarea>
@error('description')
<p class="text-red-500 text-sm mt-1">{{ $message }}</p>
@enderror
</div>
<div class="flex gap-4 pt-4">
<button type="submit" class="bg-blue-600 text-white px-6 py-2 rounded-md hover:bg-blue-700">Update Equipment</button>
<a href="{{ route('equipment.index') }}" class="bg-gray-200 text-gray-700 px-6 py-2 rounded-md hover:bg-gray-300">Cancel</a>
</div>
</form>
</div>
@endsection
How does the edit form differ from create?
- @method('PUT') adds a hidden field that tells Laravel to treat this POST request as a PUT request. HTML forms only support GET and POST natively, so Laravel uses this workaround for update operations.
- Each input uses old('field', $equipment->field) which displays the previously submitted value if validation failed, or falls back to the existing database value.
- Save edit.blade.php.
- Finally, create resources/views/equipment/show.blade.php and paste this code:
@extends('layouts.app')
@section('title', $equipment->name)
@section('content')
<div class="max-w-2xl mx-auto">
<div class="bg-white rounded-lg shadow p-6">
<div class="flex justify-between items-start mb-6">
<h1 class="text-2xl font-bold text-gray-800">{{ $equipment->name }}</h1>
@if($equipment->status === 'available')
<span class="px-3 py-1 text-sm font-semibold rounded-full bg-green-100 text-green-800">Available</span>
@elseif($equipment->status === 'in-use')
<span class="px-3 py-1 text-sm font-semibold rounded-full bg-yellow-100 text-yellow-800">In Use</span>
@else
<span class="px-3 py-1 text-sm font-semibold rounded-full bg-red-100 text-red-800">Maintenance</span>
@endif
</div>
<dl class="grid grid-cols-1 gap-4">
<div>
<dt class="text-sm font-medium text-gray-500">Serial Number</dt>
<dd class="text-gray-900">{{ $equipment->serial_number }}</dd>
</div>
<div>
<dt class="text-sm font-medium text-gray-500">Category</dt>
<dd class="text-gray-900">{{ $equipment->category->name }}</dd>
</div>
<div>
<dt class="text-sm font-medium text-gray-500">Purchase Date</dt>
<dd class="text-gray-900">{{ $equipment->purchase_date ?? 'Not specified' }}</dd>
</div>
<div>
<dt class="text-sm font-medium text-gray-500">Description</dt>
<dd class="text-gray-900">{{ $equipment->description ?? 'No description provided.' }}</dd>
</div>
<div>
<dt class="text-sm font-medium text-gray-500">Added</dt>
<dd class="text-gray-900">{{ $equipment->created_at->format('M d, Y') }}</dd>
</div>
</dl>
<div class="flex gap-4 mt-8 pt-4 border-t">
<a href="{{ route('equipment.edit', $equipment) }}" class="bg-indigo-600 text-white px-4 py-2 rounded-md hover:bg-indigo-700">Edit</a>
<form action="{{ route('equipment.destroy', $equipment) }}" method="POST" onsubmit="return confirm('Are you sure you want to delete this equipment?')">
@csrf
@method('DELETE')
<button type="submit" class="bg-red-600 text-white px-4 py-2 rounded-md hover:bg-red-700">Delete</button>
</form>
<a href="{{ route('equipment.index') }}" class="bg-gray-200 text-gray-700 px-4 py-2 rounded-md hover:bg-gray-300">Back to List</a>
</div>
</div>
</div>
@endsection
What does the show view do?
- The <dl> (description list) element provides semantic structure for label-value pairs. Screen readers and search engines understand this format better than generic divs.
- The delete button lives inside its own form with a confirmation prompt. This prevents accidental deletions.
- The null coalescing operator (??) displays a fallback value when optional fields like purchase_date or description are empty.
- Save show.blade.php.
You won't be able to test the edit or show views yet because no equipment items exist in the database. You'll add items in the next step to verify these views work correctly.
Form not submitting or showing errors?
- Make sure @csrf is the first line inside every <form> tag. Without it, Laravel rejects the request with a 419 error.
- The edit form needs both @csrf and @method('PUT'). Missing the method directive causes a 405 Method Not Allowed error.
- If you see "Route [equipment.store] not defined", check that routes/web.php still has the resource route registered.
Help me troubleshoot my Blade form views.
✔️ Awesome, I've got everything!
Great. Double check you've saved all three files before moving on.
ⓧ I'd like to double check the full code
Here is create.blade.php:
@extends('layouts.app')
@section('title', 'Add Equipment')
@section('content')
<div class="max-w-2xl mx-auto">
<h1 class="text-2xl font-bold text-gray-800 mb-6">Add New Equipment</h1>
<form method="POST" action="{{ route('equipment.store') }}" class="bg-white rounded-lg shadow p-6 space-y-4">
@csrf
<div>
<label for="name" class="block text-sm font-medium text-gray-700 mb-1">Name</label>
<input type="text" name="name" id="name" value="{{ old('name') }}" required
class="w-full border border-gray-300 rounded-md px-3 py-2 @error('name') border-red-500 @enderror">
@error('name')
<p class="text-red-500 text-sm mt-1">{{ $message }}</p>
@enderror
</div>
<div>
<label for="serial_number" class="block text-sm font-medium text-gray-700 mb-1">Serial Number</label>
<input type="text" name="serial_number" id="serial_number" value="{{ old('serial_number') }}" required
class="w-full border border-gray-300 rounded-md px-3 py-2 @error('serial_number') border-red-500 @enderror">
@error('serial_number')
<p class="text-red-500 text-sm mt-1">{{ $message }}</p>
@enderror
</div>
<div>
<label for="category_id" class="block text-sm font-medium text-gray-700 mb-1">Category</label>
<select name="category_id" id="category_id" required
class="w-full border border-gray-300 rounded-md px-3 py-2 @error('category_id') border-red-500 @enderror">
<option value="">Select a category</option>
@foreach($categories as $category)
<option value="{{ $category->id }}" @selected(old('category_id') == $category->id)>{{ $category->name }}</option>
@endforeach
</select>
@error('category_id')
<p class="text-red-500 text-sm mt-1">{{ $message }}</p>
@enderror
</div>
<div>
<label for="status" class="block text-sm font-medium text-gray-700 mb-1">Status</label>
<select name="status" id="status" required
class="w-full border border-gray-300 rounded-md px-3 py-2 @error('status') border-red-500 @enderror">
<option value="available" @selected(old('status') === 'available')>Available</option>
<option value="in-use" @selected(old('status') === 'in-use')>In Use</option>
<option value="maintenance" @selected(old('status') === 'maintenance')>Maintenance</option>
</select>
@error('status')
<p class="text-red-500 text-sm mt-1">{{ $message }}</p>
@enderror
</div>
<div>
<label for="purchase_date" class="block text-sm font-medium text-gray-700 mb-1">Purchase Date</label>
<input type="date" name="purchase_date" id="purchase_date" value="{{ old('purchase_date') }}"
class="w-full border border-gray-300 rounded-md px-3 py-2 @error('purchase_date') border-red-500 @enderror">
@error('purchase_date')
<p class="text-red-500 text-sm mt-1">{{ $message }}</p>
@enderror
</div>
<div>
<label for="description" class="block text-sm font-medium text-gray-700 mb-1">Description</label>
<textarea name="description" id="description" rows="3"
class="w-full border border-gray-300 rounded-md px-3 py-2 @error('description') border-red-500 @enderror">{{ old('description') }}</textarea>
@error('description')
<p class="text-red-500 text-sm mt-1">{{ $message }}</p>
@enderror
</div>
<div class="flex gap-4 pt-4">
<button type="submit" class="bg-blue-600 text-white px-6 py-2 rounded-md hover:bg-blue-700">Save Equipment</button>
<a href="{{ route('equipment.index') }}" class="bg-gray-200 text-gray-700 px-6 py-2 rounded-md hover:bg-gray-300">Cancel</a>
</div>
</form>
</div>
@endsection
Here is edit.blade.php:
@extends('layouts.app')
@section('title', 'Edit Equipment')
@section('content')
<div class="max-w-2xl mx-auto">
<h1 class="text-2xl font-bold text-gray-800 mb-6">Edit: {{ $equipment->name }}</h1>
<form method="POST" action="{{ route('equipment.update', $equipment) }}" class="bg-white rounded-lg shadow p-6 space-y-4">
@csrf
@method('PUT')
<div>
<label for="name" class="block text-sm font-medium text-gray-700 mb-1">Name</label>
<input type="text" name="name" id="name" value="{{ old('name', $equipment->name) }}" required
class="w-full border border-gray-300 rounded-md px-3 py-2 @error('name') border-red-500 @enderror">
@error('name')
<p class="text-red-500 text-sm mt-1">{{ $message }}</p>
@enderror
</div>
<div>
<label for="serial_number" class="block text-sm font-medium text-gray-700 mb-1">Serial Number</label>
<input type="text" name="serial_number" id="serial_number" value="{{ old('serial_number', $equipment->serial_number) }}" required
class="w-full border border-gray-300 rounded-md px-3 py-2 @error('serial_number') border-red-500 @enderror">
@error('serial_number')
<p class="text-red-500 text-sm mt-1">{{ $message }}</p>
@enderror
</div>
<div>
<label for="category_id" class="block text-sm font-medium text-gray-700 mb-1">Category</label>
<select name="category_id" id="category_id" required
class="w-full border border-gray-300 rounded-md px-3 py-2 @error('category_id') border-red-500 @enderror">
<option value="">Select a category</option>
@foreach($categories as $category)
<option value="{{ $category->id }}" @selected(old('category_id', $equipment->category_id) == $category->id)>{{ $category->name }}</option>
@endforeach
</select>
@error('category_id')
<p class="text-red-500 text-sm mt-1">{{ $message }}</p>
@enderror
</div>
<div>
<label for="status" class="block text-sm font-medium text-gray-700 mb-1">Status</label>
<select name="status" id="status" required
class="w-full border border-gray-300 rounded-md px-3 py-2 @error('status') border-red-500 @enderror">
<option value="available" @selected(old('status', $equipment->status) === 'available')>Available</option>
<option value="in-use" @selected(old('status', $equipment->status) === 'in-use')>In Use</option>
<option value="maintenance" @selected(old('status', $equipment->status) === 'maintenance')>Maintenance</option>
</select>
@error('status')
<p class="text-red-500 text-sm mt-1">{{ $message }}</p>
@enderror
</div>
<div>
<label for="purchase_date" class="block text-sm font-medium text-gray-700 mb-1">Purchase Date</label>
<input type="date" name="purchase_date" id="purchase_date" value="{{ old('purchase_date', $equipment->purchase_date) }}"
class="w-full border border-gray-300 rounded-md px-3 py-2 @error('purchase_date') border-red-500 @enderror">
@error('purchase_date')
<p class="text-red-500 text-sm mt-1">{{ $message }}</p>
@enderror
</div>
<div>
<label for="description" class="block text-sm font-medium text-gray-700 mb-1">Description</label>
<textarea name="description" id="description" rows="3"
class="w-full border border-gray-300 rounded-md px-3 py-2 @error('description') border-red-500 @enderror">{{ old('description', $equipment->description) }}</textarea>
@error('description')
<p class="text-red-500 text-sm mt-1">{{ $message }}</p>
@enderror
</div>
<div class="flex gap-4 pt-4">
<button type="submit" class="bg-blue-600 text-white px-6 py-2 rounded-md hover:bg-blue-700">Update Equipment</button>
<a href="{{ route('equipment.index') }}" class="bg-gray-200 text-gray-700 px-6 py-2 rounded-md hover:bg-gray-300">Cancel</a>
</div>
</form>
</div>
@endsection
Here is show.blade.php:
@extends('layouts.app')
@section('title', $equipment->name)
@section('content')
<div class="max-w-2xl mx-auto">
<div class="bg-white rounded-lg shadow p-6">
<div class="flex justify-between items-start mb-6">
<h1 class="text-2xl font-bold text-gray-800">{{ $equipment->name }}</h1>
@if($equipment->status === 'available')
<span class="px-3 py-1 text-sm font-semibold rounded-full bg-green-100 text-green-800">Available</span>
@elseif($equipment->status === 'in-use')
<span class="px-3 py-1 text-sm font-semibold rounded-full bg-yellow-100 text-yellow-800">In Use</span>
@else
<span class="px-3 py-1 text-sm font-semibold rounded-full bg-red-100 text-red-800">Maintenance</span>
@endif
</div>
<dl class="grid grid-cols-1 gap-4">
<div>
<dt class="text-sm font-medium text-gray-500">Serial Number</dt>
<dd class="text-gray-900">{{ $equipment->serial_number }}</dd>
</div>
<div>
<dt class="text-sm font-medium text-gray-500">Category</dt>
<dd class="text-gray-900">{{ $equipment->category->name }}</dd>
</div>
<div>
<dt class="text-sm font-medium text-gray-500">Purchase Date</dt>
<dd class="text-gray-900">{{ $equipment->purchase_date ?? 'Not specified' }}</dd>
</div>
<div>
<dt class="text-sm font-medium text-gray-500">Description</dt>
<dd class="text-gray-900">{{ $equipment->description ?? 'No description provided.' }}</dd>
</div>
<div>
<dt class="text-sm font-medium text-gray-500">Added</dt>
<dd class="text-gray-900">{{ $equipment->created_at->format('M d, Y') }}</dd>
</div>
</dl>
<div class="flex gap-4 mt-8 pt-4 border-t">
<a href="{{ route('equipment.edit', $equipment) }}" class="bg-indigo-600 text-white px-4 py-2 rounded-md hover:bg-indigo-700">Edit</a>
<form action="{{ route('equipment.destroy', $equipment) }}" method="POST" onsubmit="return confirm('Are you sure you want to delete this equipment?')">
@csrf
@method('DELETE')
<button type="submit" class="bg-red-600 text-white px-4 py-2 rounded-md hover:bg-red-700">Delete</button>
</form>
<a href="{{ route('equipment.index') }}" class="bg-gray-200 text-gray-700 px-4 py-2 rounded-md hover:bg-gray-300">Back to List</a>
</div>
</div>
</div>
@endsection
Your views are in place and the application has a full visual interface. Next up, you'll wire up the filter dropdowns so they actually filter the equipment list by status and category.
Add Filtering and Status Management
Your controller, views, and models are all built. The application can create, read, update, and delete equipment items.
In this step, you will understand how the filtering and status badge features work in your existing code, then prove everything functions correctly by adding real data and testing the full workflow.
In this step, get ready to:
- Understand how the controller applies query scopes for filtering.
- Understand how the index view submits filter parameters and styles status badges.
- Test the full CRUD workflow with real equipment data.
Review the controller's filtering logic
The index method in your controller already handles filtering. It reads query parameters from the URL and applies query scopes conditionally.
- In VS Code, open app/Http/Controllers/EquipmentController.php.
- Find the index method at the top of the class.
public function index(Request $request): View
{
$query = Equipment::with('category');
if ($request->filled('status')) {
$query->status($request->status);
}
if ($request->filled('category_id')) {
$query->inCategory($request->category_id);
}
$equipment = $query->latest()->paginate(10);
$categories = Category::all();
return view('equipment.index', [
'equipment' => $equipment,
'categories' => $categories,
'selectedStatus' => $request->status,
'selectedCategory' => $request->category_id,
]);
}
How does the filtering work?
The $request->filled('status') check returns true only when the query parameter exists AND is not empty. This means filters are only applied when the user actively selects one.
The status() and inCategory() calls invoke the query scopes you defined on the Equipment model. They chain onto the same $query builder, so both filters can be combined.
The selectedStatus and selectedCategory values are passed back to the view so the dropdowns can retain their selection after the page reloads.
Review the filter form and status badges in the view
The index Blade view contains a GET form that submits filter selections as URL parameters. It also displays status badges with color coding so users can quickly scan equipment availability.
- Open resources/views/equipment/index.blade.php.
- Find the <form method="GET"> block near the top of the content section.
<form method="GET" action="{{ route('equipment.index') }}" class="flex gap-4 items-end">
<div>
<label for="status" class="block text-sm font-medium text-gray-700 mb-1">Status</label>
<select name="status" id="status" class="border border-gray-300 rounded-md px-3 py-2">
<option value="">All Statuses</option>
<option value="available" @selected($selectedStatus === 'available')>Available</option>
<option value="in-use" @selected($selectedStatus === 'in-use')>In Use</option>
<option value="maintenance" @selected($selectedStatus === 'maintenance')>Maintenance</option>
</select>
</div>
<div>
<label for="category_id" class="block text-sm font-medium text-gray-700 mb-1">Category</label>
<select name="category_id" id="category_id" class="border border-gray-300 rounded-md px-3 py-2">
<option value="">All Categories</option>
@foreach($categories as $category)
<option value="{{ $category->id }}" @selected($selectedCategory == $category->id)>{{ $category->name }}</option>
@endforeach
</select>
</div>
<button type="submit" class="bg-gray-800 text-white px-4 py-2 rounded-md hover:bg-gray-900">Filter</button>
<a href="{{ route('equipment.index') }}" class="text-gray-600 hover:text-gray-800 px-4 py-2">Clear</a>
</form>
Why a GET form instead of POST?
Filters use GET because the result is a read-only page view, not a data modification. GET parameters appear in the URL (like /equipment?status=available), which means users can bookmark or share filtered views.
The @selected directive compares the current dropdown value against $selectedStatus or $selectedCategory. If they match, the option stays selected after filtering. The Clear link reloads the index without any query string, resetting both dropdowns.
- Scroll down in the same file to find the status badge rendering inside the table body.
@if($item->status === 'available')
<span class="px-2 py-1 text-xs font-semibold rounded-full bg-green-100 text-green-800">Available</span>
@elseif($item->status === 'in-use')
<span class="px-2 py-1 text-xs font-semibold rounded-full bg-yellow-100 text-yellow-800">In Use</span>
@else
<span class="px-2 py-1 text-xs font-semibold rounded-full bg-red-100 text-red-800">Maintenance</span>
@endif
What do the badge colors mean?
Each status maps to a distinct color so users can scan the table at a glance. Green means available and ready to use. Yellow means the item is currently in use by someone. Red means the item is out for maintenance and unavailable.
Test the full CRUD and filtering workflow
The code is complete. Now you need to prove it works by adding real equipment items and exercising every feature.
- In your browser, visit http://localhost:8000/equipment.
- Click the Add Equipment button in the top navigation bar.
- Fill in the form with these values for your first item:
- Name: Dell Latitude Laptop.
- Serial Number: SN-1001-DL.
- Category: Electronics.
- Status: Available.
- Purchase Date: any date you like.
- Click Save Equipment.
You should see a green success banner and the laptop listed in the table.
- Add a second item with Name: Standing Desk, Serial Number: SN-2001-SD, Category: Furniture, Status: In Use.
- Add a third item with Name: Oscilloscope, Serial Number: SN-3001-OS, Category: Lab Equipment, Status: Maintenance.
Your index page should now display three items with green, yellow, and red status badges.
What should I see?
The table shows all three items. The Dell Latitude Laptop has a green "Available" badge. The Standing Desk has a yellow "In Use" badge. The Oscilloscope has a red "Maintenance" badge.
- Select Available from the Status dropdown.
- Click Filter.
Only the Dell Latitude Laptop should appear. Notice the URL now contains ?status=available and the dropdown retains your selection.
- Click Clear to reset the filters.
All three items should reappear.
- Select Lab Equipment from the Category dropdown and click Filter.
Only the Oscilloscope should appear. This confirms category filtering works independently.
- Click Clear again.
- Click Edit next to the Dell Latitude Laptop.
- Change the Status dropdown to In Use.
- Click Update Equipment.
The laptop should now display a yellow "In Use" badge instead of green.
- Click Delete next to the Oscilloscope.
- Confirm the deletion when the browser asks "Are you sure?".
The Oscilloscope disappears from the table and a green success banner confirms the deletion.
Filters not working?
Make sure your development server is still running. If you stopped it, run composer run dev again from the equipment-manager folder.
If filtering returns all items regardless of selection, check that the form uses method="GET" in index.blade.php. A POST form would not pass parameters in the URL where the controller expects them.
If you see an error about an undefined variable $selectedStatus, confirm your controller's index method passes both selectedStatus and selectedCategory to the view.
Your Equipment Management System is fully functional. You can add items, filter by status or category, edit details, and delete records. Next up, you will write automated tests to prove this all works without manual clicking.
Secret mission
Write Feature Tests
Your Equipment Manager works perfectly when you test it by hand. But what happens when your team pushes a code change that accidentally breaks the create form? Automated tests catch those regressions before they reach production.
Clean Up Your Resources
Clean Up Your Resources
This project runs entirely on your local machine, so there are no ongoing costs. Decide whether to keep your resources running, pause them to come back later, or delete them entirely.
Resources you used:
- The equipment-manager project folder on your Desktop.
- The equipment_manager MySQL database.
- The Laravel development server (started with composer run dev).
Keep everything running
No action needed. Choose this if you are still actively building or experimenting with the project.
- Your project files stay in the equipment-manager folder on your Desktop.
- Your MySQL database remains available for future use.
- The development server stops automatically when you close the terminal window.
Pause - I'll come back to this later
Stop the running development server to free up your terminal. Your files and database stay intact for when you return.
- In the terminal running the dev server, press Ctrl+C to stop it.
- Your project folder and MySQL database remain untouched.
- To resume later, open a terminal in the equipment-manager folder and run composer run dev again.
Delete - I don't want to use this again
Remove all project resources and start fresh. This deletes your code and database permanently.
- In the terminal running the dev server, press Ctrl+C to stop it.
- Drop the MySQL database by running:
mysql -u root -p -e "DROP DATABASE equipment_manager;"
- Delete the project folder by running:
cd ~/Desktop
rm -rf equipment-manager
- Confirm the folder is gone by running ls ~/Desktop and verifying equipment-manager no longer appears.
Nice Work!
Nice Work!
You just built a fully functional Equipment Management System using Laravel 13 from scratch. Your application handles real CRUD operations, validates user input, and filters data with professional patterns that any development team would recognize.
You learned how to:
- Designed a relational database with migrations, Eloquent models, relationships, and seeded initial data.
- Implemented full CRUD operations using a resource controller, Form Request validation classes, and Blade views styled with Tailwind CSS.
- Added query scopes to filter equipment by status and category, keeping controller logic clean and composable.
- Secret Mission: Wrote feature tests with PHPUnit that automatically verify your CRUD operations work correctly.
Ready to quiz yourself?