Laravel Controllers: From Simple to Scalable
Sunday, November 17, 2024
// 8 min read
Table of Contents
Controllers are useful for organizing request-handling logic within your application. They play a crucial part in the MVC design pattern, which stands for Model, View, and Controller. You might already be familiar with MVC, but let me briefly explain it.
The Model is responsible for data logic and interactions with the database, the View displays data to the user, and the Controller acts as a bridge between these two layers and processes requests and responses between the application and the end user. Laravel is built on this design pattern.
Basic Controllers
To generate a controller class, you can use the make:controller
artisan command.
php artisan make:controller ProductController
In the controller, you need to create methods for your route definitions. I'll be creating a controller that handles CRUD (Create, Read, Update, Delete) operations for my products.
I need to implement the following methods in order to cover everything:
index
create
store
show
edit
update
destroy
For instance, to list all products, I need an index method that returns a view with all the active products:
public function index()
{
return view('products.index', [
'products' => Product::active()->get(),
]);
}
And in my products/index.blade.php
file I can create a simple template to display the products using Tailwind CSS:
<x-app-layout>
<div class="px-4 mx-auto max-w-7xl sm:px-6 lg:px-8">
<h1 class="py-6 text-3xl font-bold">Our Products</h1>
<div class="grid grid-cols-1 gap-6 md:grid-cols-3">
@forelse ($products as $product)
<div class="mb-4">
<div class="overflow-hidden bg-white rounded-lg shadow-md">
@if ($product->image)
<img src="{{ $product->image_link }}" class="object-cover w-full h-48"
alt="{{ $product->name }}">
@endif
<div class="p-4">
<h5 class="mb-2 text-xl font-semibold">
{{ $product->name }}
</h5>
<p class="mb-3 text-gray-600">
{{ Str::limit($product->description, 100) }}
</p>
<p class="mb-4 text-lg font-bold text-gray-900">
${{ number_format($product->price, 2) }}
</p>
<a href="{{ route('products.show', $product) }}"
class="inline-block px-4 py-2 text-white transition-colors bg-blue-600 rounded-lg hover:bg-blue-700">
View Details
</a>
</div>
</div>
</div>
@empty
<div class="col-span-3">
<p class="text-center text-gray-500">No products available at the moment.</p>
</div>
@endforelse
</div>
</div>
</x-app-layout>
Resource Controller
Creating these manually can be quite a hassle. Luckily, Laravel offers an option to generate a skeleton for all of these methods through what are called resource controllers.
php artisan make:controller ProductController --resource --model=Product
By using the --resource
option you can automatically create all the necessary methods. I've also added another option called --model
, which allows the controller to use your specified model in each method. I've written an entire article about resource controllers that you can read HERE.
Let's explore the other methods.
Create and Store
To begin, we need a form for creating a new product. Each of my products has categories and brands. So we need a list of categories and brands from which the users can select from.
In the controller's create
method, we will load these lists and pass them to the create view.
/**
* Show the form for creating a new resource.
*/
public function create()
{
$categories = Category::all();
$brands = Brand::all();
return view('products.create', compact('categories', 'brands'));
}
My create view includes a Product form, which is a separate Blade template.
<x-app-layout>
<div class="px-4 mx-auto max-w-7xl sm:px-6 lg:px-8">
<h1 class="py-6 text-3xl font-bold">Create Product</h1>
@include('products.form', [
'categories' => $categories,
'brands' => $brands,
])
</div>
</x-app-layout>
This approach allows me to reuse the form for the update
method. I added some conditions to prefill the form with product values when updating an existing product.
Here is the product form template:
<form action="{{ isset($product) ? route('products.update', $product) : route('products.store') }}" method="POST"
enctype="multipart/form-data">
@csrf
@if (isset($product))
@method('PUT')
@endif
<div class="grid grid-cols-1 gap-6 md:grid-cols-2">
<div class="mb-4">
<label for="name" class="block mb-2 text-sm font-medium text-gray-600">Name</label>
<input type="text" name="name" id="name"
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring focus:ring-blue-100 focus:border-blue-500"
value="{{ old('name', $product->name ?? '') }}">
@error('name')
<p class="mt-1 text-sm text-red-500">{{ $message }}</p>
@enderror
</div>
<div class="mb-4">
<label for="description" class="block mb-2 text-sm font-medium text-gray-600">Description</label>
<textarea name="description" id="description"
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring focus:ring-blue-100 focus:border-blue-500">{{ old('description', $product->description ?? '') }}</textarea>
@error('description')
<p class="mt-1 text-sm text-red-500">{{ $message }}</p>
@enderror
</div>
<div class="mb-4">
<label for="price" class="block mb-2 text-sm font-medium text-gray-600">Price</label>
<input type="text" name="price" id="price"
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring focus:ring-blue-100 focus:border-blue-500"
value="{{ old('price', $product->price ?? '') }}" pattern="^\d+(\.\d{1,2})?$"
title="Please enter a valid price (e.g., 19.99)">
@error('price')
<p class="mt-1 text-sm text-red-500">{{ $message }}</p>
@enderror
</div>
<div class="mb-4">
<label for="stock" class="block mb-2 text-sm font-medium text-gray-600">Stock</label>
<input type="number" name="stock" id="stock"
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring focus:ring-blue-100 focus:border-blue-500"
value="{{ old('stock', $product->stock ?? '') }}">
@error('stock')
<p class="mt-1 text-sm text-red-500">{{ $message }}</p>
@enderror
</div>
<div class="mb-4">
<label for="image" class="block mb-2 text-sm font-medium text-gray-600">Image</label>
@isset($product->image)
<img src="{{ asset('storage/' . $product->image) }}" class="object-cover w-full h-48"
alt="{{ $product->name }}">
@endisset
<input type="file" name="image" id="image"
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring focus:ring-blue-100 focus:border-blue-500">
@error('image')
<p class="mt-1 text-sm text-red-500">{{ $message }}</p>
@enderror
</div>
<div class="mb-4">
<label for="category_id" class="block mb-2 text-sm font-medium text-gray-600">Category</label>
<select name="category_id" id="category_id"
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring focus:ring-blue-100 focus:border-blue-500">
<option value="">Select a category</option>
@foreach ($categories as $category)
<option value="{{ $category->id }}"
{{ old('category_id', $product->category_id ?? '') == $category->id ? 'selected' : '' }}>
{{ $category->name }}
</option>
@endforeach
</select>
@error('category_id')
<p class="mt-1 text-sm text-red-500">{{ $message }}</p>
@enderror
</div>
<div class="mb-4">
<label for="brand_id" class="block mb-2 text-sm font-medium text-gray-600">Brand</label>
<select name="brand_id" id="brand_id"
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring focus:ring-blue-100 focus:border-blue-500">
<option value="">Select a brand</option>
@foreach ($brands as $brand)
<option value="{{ $brand->id }}"
{{ old('brand_id', $product->brand_id ?? '') == $brand->id ? 'selected' : '' }}>
{{ $brand->name }}
</option>
@endforeach
</select>
@error('brand_id')
<p class="mt-1 text-sm text-red-500">{{ $message }}</p>
@enderror
</div>
<div class="mb-4">
<label for="active" class="block mb-2 text-sm font-medium text-gray-600">Active</label>
<input type="hidden" name="active" value="0">
<input type="checkbox" name="active" id="active" value="1"
class="w-4 h-4 text-blue-500 border border-gray-300 rounded focus:outline-none focus:ring focus:ring-blue-100 focus:border-blue-500"
{{ old('active', $product->active ?? false) ? 'checked' : '' }}>
@error('active')
<p class="mt-1 text-sm text-red-500">{{ $message }}</p>
@enderror
</div>
</div>
<div class="mt-6">
<button type="submit" class="px-4 py-2 text-white transition-colors bg-blue-600 rounded-lg hover:bg-blue-700">
{{ isset($product) ? 'Update Product' : 'Create Product' }}
</button>
</div>
</form>
When the form is submitted, it calls the store
route.
public function store(Request $request)
{
$request->validate([
'name' => 'required|string|unique:products|max:100',
'description' => 'required|string|max:1500',
'price' => 'required|numeric|min:0',
'stock' => 'required|integer|min:0',
'image' => 'required|image',
'category_id' => 'required|exists:categories,id',
'brand_id' => 'required|exists:brands,id',
'active' => 'nullable|boolean',
]);
$product = new Product();
$product->name = $request->name;
$product->slug = Str::slug($request->name);
$product->description = $request->description;
$product->price = $request->price;
$product->stock = $request->stock;
$product->image = $request->file('image')->store('products', 'public');
$product->category_id = $request->category_id;
$product->brand_id = $request->brand_id;
$product->active = $request->has('active');
$product->save();
return redirect()->route('products.index');
}
First, the request is validated, if any required inputs are missing or invalid, it returns a 422 response that indicates the input errors.
If the validation is successful, the new product is saved, and the user is redirected back to the product list page.
Note, that I am saving the images inside the storage
folder. By default, this is not a publicly accessible directory, you can use the following artisan command to create a symbolic link in order to access the storage/public
folder files inside your browser.
php artisan storage:link
This is how the image link looks like:
http://localhost:8000/storage/products/ou3JbhZr1t4cJK4mUqQq8rez9bUxX9EnBiTBYOiv.jpg
Show
We need to display product details when a user selects a product from the list.
public function show(Product $product)
{
return view('products.show', compact('product'));
}
I am using this simple template to do so:
<x-app-layout>
<div class="px-4 mx-auto max-w-7xl sm:px-6 lg:px-8">
<div class="flex items-center justify-between py-6">
<h1 class="text-3xl font-bold">Product Details</h1>
<a href="{{ route('products.index') }}"
class="inline-block px-4 py-2 text-white transition-colors bg-blue-600 rounded-lg hover:bg-blue-700">
Back to Products
</a>
</div>
<div class="mb-4">
<div class="overflow-hidden bg-white rounded-lg shadow-md">
@isset($product->image)
<img src="{{ $product->image_link }}" class="object-cover w-full h-96" alt="{{ $product->name }}">
@else
<div class="flex items-center justify-center w-full h-48 bg-gray-200">
<span class="text-gray-500">No image available</span>
</div>
@endisset
<div class="p-4">
<h5 class="mb-2 text-xl font-semibold">
{{ $product->name }}
</h5>
<p class="mb-3 text-gray-600">
{!! nl2br(e($product->description)) !!}
</p>
<p class="mb-4 text-lg font-bold text-gray-900">
${{ number_format($product->price, 2) }}
</p>
<p class="mb-4 text-gray-600">
Stock: {{ $product->stock }}
</p>
<p class="mb-4 text-gray-600">
Category: {{ $product->category->name ?? 'Uncategorized' }}
</p>
<p class="mb-4 text-gray-600">
Brand: {{ $product->brand->name ?? 'Uncategorized' }}
</p>
</div>
</div>
</div>
</div>
</x-app-layout>
Edit and update
Now I would like to edit an existing product. To achieve this, first I need to add an edit button to the product details template.
<div class="flex justify-end mt-6">
<a href="{{ route('products.edit', $product) }}"
class="inline-block px-4 py-2 text-white transition-colors bg-green-600 rounded-lg hover:bg-green-700">
Edit Product
</a>
</div>
The button redirects the user to the selected product's edit page.
public function edit(Product $product)
{
$categories = Category::all();
$brands = Brand::all();
return view('products.edit', compact('product', 'categories', 'brands'));
}
In the edit template, I included the product form to preload the form fields with the current values from the Product model.
<x-app-layout>
<div class="px-4 mx-auto max-w-7xl sm:px-6 lg:px-8">
<h1 class="py-6 text-3xl font-bold">Edit Product</h1>
@include('products.form', [
'product' => $product,
'categories' => $categories,
'brands' => $brands,
])
</div>
</x-app-layout>
The update method is quite similar to the store method, but instead of adding a new product, it updates the details of an existing product.
public function update(Request $request, Product $product)
{
$request->validate([
'name' => 'required|string|max:100|unique:products,name,' . $product->id,
'description' => 'required|string|max:1500',
'price' => 'required|numeric|min:0',
'stock' => 'required|integer|min:0',
'image' => 'nullable|image',
'category_id' => 'required|exists:categories,id',
'brand_id' => 'required|exists:brands,id',
'active' => 'nullable|boolean',
]);
$product->name = $request->name;
$product->slug = Str::slug($request->name);
$product->description = $request->description;
$product->price = $request->price;
$product->stock = $request->stock;
if ($request->hasFile('image')) {
$product->image = $request->file('image')->store('products', 'public');
}
$product->category_id = $request->category_id;
$product->brand_id = $request->brand_id;
$product->active = $request->has('active');
$product->save();
return redirect()->route('products.index');
}
Delete
There’s only one method left to implement: deleting a product. I’ve added a delete button next to the edit button on the product page. Notice the additional confirmation, this requires the user to confirm if they really want to delete the product.
<form action="{{ route('products.destroy', $product) }}" method="POST"
onsubmit="return confirm('Are you sure you want to delete this product?');">
@csrf
@method('DELETE')
<div class="flex justify-end mt-6">
<button type="submit"
class="inline-block px-4 py-2 text-white transition-colors bg-red-600 rounded-lg hover:bg-red-700">
Delete Product
</button>
</div>
</form>
The method in the controller is simple:
public function destroy(Product $product)
{
$product->delete();
return redirect()->route('products.index');
}
Policy
I've added the edit and delete buttons, but I would like to restrict those to only show for admin users.
To achieve this, I am using a policy class. Policies are like "rules" for who can do what with specific models, making it easy to control access to features like creating, editing, or deleting.
To generate a Policy class, you can use an artisan command:
php artisan make:policy ProductPolicy --model=Product
In the methods, I can add the desired conditions. Besides restricting the create, update and delete methods, I would like to control that regular users cannot load the detail page of inactive products, but it would be accessible for admin users. So I add my condition inside those methods:
<?php
namespace App\Policies;
use App\Models\Product;
use App\Models\User;
class ProductPolicy
{
/**
* Determine whether the user can view any models.
*/
public function viewAny(User $user): bool
{
return $user->isAdmin();
}
/**
* Determine whether the user can view the model.
*/
public function view(?User $user, Product $product): bool
{
return $user && $user->isAdmin() || $product->active;
}
/**
* Determine whether the user can create models.
*/
public function create(User $user): bool
{
return $user->isAdmin();
}
/**
* Determine whether the user can update the model.
*/
public function update(User $user, Product $product): bool
{
return $user->isAdmin();
}
/**
* Determine whether the user can delete the model.
*/
public function delete(User $user, Product $product): bool
{
return $user->isAdmin();
}
}
All gates and policies automatically return false if the HTTP request was not initiated by an authenticated user. However, you may allow requests with guest users to pass through to your gates and policies by declaring the user parameter optional (?User $user
)
I have a simple method called isAdmin
in my user model which checks if the user has an admin role.
After my Policy is ready, I need to restrict the routes. I can achieve this via the can
middleware. However, I prefer to handle these in my controller methods, also this article focuses on Laravel controllers, so I would like to present that approach instead.
To authorize actions, you can use the Gate facade's authorize method. You just need to pass the name of the method and the relevant model. Based on the model, the related Policy class is used. Let's go through the methods one by one.
Authorize Create & Store
You might notice that there are no model parameters in these Policy methods. So instead of passing a model, you should include the class name, so the authorize method can identify which Policy to use by. You should put this as the first line after the opening parenthesis of the method.
public function create()
{
Gate::authorize('create', Product::class);
...
}
public function store(Request $request)
{
Gate::authorize('create', Product::class);
...
}
Authorize Show, Edit, Update, and Delete
Similarly, you can add the authorize method to the Show, Edit, Update, and Delete method. Here you can pass the Product model as the second parameter.
public function edit(Product $product)
{
Gate::authorize('update', $product);
...
}
public function update(Request $request, Product $product)
{
Gate::authorize('update', $product);
...
}
public function destroy(Product $product)
{
Gate::authorize('delete', $product);
...
}
The condition for the Show method is a bit different because instead of the unauthorized message, I want the app to show a "Page Not Found" message.
public function show(Product $product)
{
if (Gate::denies('view', $product)) {
abort(404);
}
...
}
Authorize in Blade templates
After securing your routes, you only need to show the action buttons if an admin user is logged in. This can easily be done with the @can
blade directive.
Here is the updated template for the buttons on the product details page:
@can('update', $product)
<div class="flex justify-end mt-6">
<a href="{{ route('products.edit', $product) }}"
class="inline-block px-4 py-2 text-white transition-colors bg-green-600 rounded-lg hover:bg-green-700">
Edit Product
</a>
</div>
@endcan
@can('delete', $product)
<form action="{{ route('products.destroy', $product) }}" method="POST"
onsubmit="return confirm('Are you sure you want to delete this product?');">
@csrf
@method('DELETE')
<div class="flex justify-end mt-6">
<button type="submit"
class="inline-block px-4 py-2 text-white transition-colors bg-red-600 rounded-lg hover:bg-red-700">
Delete Product
</button>
</div>
</form>
@endcan
You can add this Create button to the products.index.blade.php
template:
@can('create', App\Models\Product::class)
<div class="mx-auto mt-6 mb-4 max-w-7xl">
<a href="{{ route('products.create') }}"
class="inline-block px-4 py-2 text-white transition-colors bg-green-600 rounded-lg hover:bg-green-700">
Add New Product
</a>
</div>
@endcan
Form Request Validation
You might notice that the store and update methods look similar. The validation rules are almost the same, except in the update method the name
parameter ignores the product's name. Also, the image
parameter is not required because we only need to upload it again if we want to change the existing image.
Because of these minor differences, I would like to write this validation logic in a class and then use that class in both methods to validate the request parameters. For this purpose, I can use a Form request.
Let's generate the class with the artisan command:
php artisan make:request StoreProductRequest
With a bit of modification, we can use the same rules.
<?php
namespace App\Http\Requests;
use App\Models\Product;
use Illuminate\Foundation\Http\FormRequest;
class StoreProductRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
if ($this->isMethod('POST')) {
return $this->user()->can('create', Product::class);
}
return $this->user()->can('update', $this->product);
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
$productId = $this->route('product') ? $this->route('product')->id : null;
return [
'name' => 'required|string|max:100|unique:products,name,' . $productId,
'description' => 'required|string|max:1500',
'price' => 'required|numeric|min:0',
'stock' => 'required|integer|min:0',
'image' => [
$this->isMethod('POST') ? 'required' : 'nullable',
'image',
],
'category_id' => 'required|exists:categories,id',
'brand_id' => 'required|exists:brands,id',
'active' => 'nullable|boolean',
];
}
}
To check the HTTP method, use the Form Request class isMethod
function. To retrieve the parameter you can use the route
method: $this->route('product')
or if you use route model binding, which we do, just simply access the model: $this->product
.
Moreover, you can even move your authorizations to the Form Request class, but instead of using the authorize method from the Gate facade, you need to use the can
method similar to what we did in the Blade template. You need to run this on the authenticated user ($this->user()
).
After the Form request is complete, we can use it in our controller methods, you just need to swap the Request class with the StoreProductRequest
and remove the extra Gate::athorize
method calls.
public function store(StoreProductRequest $request)
{
$product = new Product();
....
}
public function update(StoreProductRequest $request, Product $product)
{
$product->name = $request->name;
....
}
Service
After we handled the duplicate validation code for store and update, we can look at the actual saving and updating process of the model. If you look at how the store method and update set the model attributes, you can note that they are pretty much the same.
I can write one function that can handle both the creation and updating of a Product. For this, I prefer to use a Service class. A Service is just a class that contains methods with reusable business logic that you can use in different parts of your application.
Because the logic I need is related to the Product model, I am going to call my class ProductService
. You need to create this manually because Laravel does not offer you an artisan command to generate services. I prefer to create a dedicated folder inside my app folder for my Services
classes.
Inside the ProductService
I add the saveProduct
method with the logic.
<?php
namespace App\Services;
use App\Models\Product;
use Illuminate\Support\Str;
use Illuminate\Http\Request;
class ProductService
{
public function saveProduct(Request $request, ?Product $product = null)
{
if (is_null($product)) {
$product = new Product();
}
$product->name = $request->name;
$product->slug = Str::slug($request->name);
$product->description = $request->description;
$product->price = $request->price;
$product->stock = $request->stock;
if ($request->hasFile('image')) {
$product->image = $request->file('image')->store('products', 'public');
}
$product->category_id = $request->category_id;
$product->brand_id = $request->brand_id;
$product->active = $request->has('active');
$product->save();
return $product;
}
}
To use this you need to first initialize the ProductService
in your controller, you can easily do this using dependency injection. Because Laravel's service container automatically resolves the ProductService
dependency by injecting an instance of it into the ProductController
constructor.
Here is the updated Controller class with the new service calls:
<?php
namespace App\Http\Controllers;
use App\Models\Brand;
use App\Models\Product;
use App\Models\Category;
use App\Services\ProductService;
use Illuminate\Support\Facades\Gate;
use App\Http\Requests\StoreProductRequest;
class ProductController extends Controller
{
public function __construct(protected ProductService $productService) {}
/**
* Display a listing of the resource.
*/
public function index()
{
return view('products.index', [
'products' => Product::active()->get(),
]);
}
/**
* Show the form for creating a new resource.
*/
public function create()
{
Gate::authorize('create', Product::class);
$categories = Category::all();
$brands = Brand::all();
return view('products.create', compact('categories', 'brands'));
}
/**
* Store a newly created resource in storage.
*/
public function store(StoreProductRequest $request)
{
$this->productService->saveProduct($request);
return redirect()->route('products.index');
}
/**
* Display the specified resource.
*/
public function show(Product $product)
{
return view('products.show', compact('product'));
}
/**
* Show the form for editing the specified resource.
*/
public function edit(Product $product)
{
Gate::authorize('update', $product);
$categories = Category::all();
$brands = Brand::all();
return view('products.edit', compact('product', 'categories', 'brands'));
}
/**
* Update the specified resource in storage.
*/
public function update(StoreProductRequest $request, Product $product)
{
$this->productService->saveProduct($request, $product);
return redirect()->route('products.index');
}
/**
* Remove the specified resource from storage.
*/
public function destroy(Product $product)
{
Gate::authorize('delete', $product);
$product->delete();
return redirect()->route('products.index');
}
}
One more thing that I would improve, is to create Service classes for the Brand and Category list queries.
Right now it does not seem necessary, but imagine if later you need to use the same Brand query somewhere else, you might think it is not a big deal, I just copy this line: Brand::all()
. Then you remember that you want to archive a few brands, so you add a status column to the brands
table, but then you need to filter out the inactive brands from the select lists on the Product form, so you go through and refactor all the Brands queries.
So for scalability, it is always better to move business logic to a separate Service or Action class even though it is just a small piece of code. But I leave this work to you.
The final code is accessible in this GitHub repository.