Laravel Observers Explained: A Guide to Eloquent Lifecycle Events
The Day My Controller Became a Junk Drawer
So, the other day I was wrestling with React hooks and component lifecycles (you know, the classic frontend mid-life crisis 😅). Amidst the chaos of state management and endless re-renders, a sudden thought struck me:
“Wait, doesn’t Laravel Eloquent have its own lifecycle events too?”
That simple question sent me straight back to the official documentation at https://laravel.com/docs/13.x/eloquent. It made me realize a painful truth: if you’re not using model lifecycle events, you’re probably writing way too much copy-paste spaghetti. Let’s look at how to stop treating your controllers like a junk drawer, shall we?
The Instant Dopamine Hit: From 60 Lines of Spaghetti to Exactly One Line
Before we dive into the painful details, let me show you the reward at the end of this journey. Here is the visual contrast we are aiming for:
// BEFORE: Manual SKU, Slug, and Image upload logic in every single controller 🤢
$data['sku'] = 'P' . str_pad(Product::max('id') + 1, 6, '0', STR_PAD_LEFT);
$data['slug'] = Str::slug($request->name) . '-' . time();
$data['image'] = $this->handleUpload($request);
Product::create($data);
// AFTER: Sleek one-liner. The background logic is completely automated 😎
Product::create($request->validated());Imagine having that clean, one-line model action not just in your web dashboard, but in your REST API endpoints and background Excel imports too. No extra lines. No copy-pasting.
To help you visualize how massive this architectural shift is, let’s look at how the data flows in both scenarios:
The Old Way: Duplication Hell 🤢
The New Way: Observer Magic 😎
Intrigued? Let’s get our hands dirty, shall we?
Imagine this
You’re building a classic product inventory system. You’ve got a simple products table with this database migration schema:
Schema::create('products', function (Blueprint $table) {
$table->id();
$table->string('sku')->unique(); // Unique SKU code (like P000001)
$table->string('slug')->unique(); // URL SEO slug
$table->string('name'); // Product name
$table->text('description')->nullable();
$table->decimal('price', 12, 2);
$table->string('image')->nullable(); // Image file path
$table->timestamps();
$table->softDeletes();
});And of course, to keep your data safe, you’ve got some validation rules in StoreProductRequest and UpdateProductRequest:
// Rules on both StoreProductRequest & UpdateProductRequest
public function rules(): array
{
return [
'name' => 'required|string|max:255',
'description' => 'nullable|string',
'price' => 'required|numeric|min:0',
'image' => 'nullable|image|mimes:jpeg,png,jpg,gif|max:2048',
];
}The Nightmare: Copy-Paste Hell (Before)
Three years ago, if I needed to auto-generate a unique SKU, create a URL slug, and upload product images, I would hardcode all that logic inside the controller. Because, “hey, it works, right?”
Here is what ProductController.php looked like back then:
class ProductController extends Controller
{
public function store(StoreProductRequest $request)
{
$data = $request->validated();
// 1. Manual SKU Generation
$nextId = Product::max('id') + 1;
$data['sku'] = 'P' . str_pad($nextId, 6, '0', STR_PAD_LEFT);
// 2. Manual Slug Generation
$data['slug'] = Str::slug($request->name) . '-' . time();
// 3. Manual Image Upload
if ($request->hasFile('image')) {
$imageName = time() . '.' . $request->image->extension();
$request->image->move(public_path('images'), $imageName);
$data['image'] = 'images/' . $imageName;
}
Product::create($data);
return redirect()->route('products.index')->with('success', 'Product created successfully.');
}
public function update(UpdateProductRequest $request, Product $product)
{
$data = $request->validated();
// Regenerate slug if product name changed
if ($product->name !== $request->name) {
$data['slug'] = Str::slug($request->name) . '-' . time();
}
// Upload new image and clean up the old one
if ($request->hasFile('image')) {
if ($product->image && file_exists(public_path($product->image))) {
unlink(public_path($product->image));
}
$imageName = time() . '.' . $request->image->extension();
$request->image->move(public_path('images'), $imageName);
$data['image'] = 'images/' . $imageName;
}
$product->update($data);
return redirect()->route('products.index')->with('success', 'Product updated successfully.');
}
}The Chaos Expands
Now, your application starts growing. Suddenly, creating a product doesn’t just happen from your admin web dashboard. You get two brand new entry points:
API/ProductController.php: Your mobile apps need a REST API to let sellers create products remotely.ImportProductsJob.php: A background job that imports thousands of products from a supplier’s CSV/Excel sheet.
Since all that SKU, Slug, and Image logic is hardcoded inside the dashboard controller, you are forced to copy-paste the exact same messy blocks to your new files.
1. Duplication in the API Controller (API/ProductController.php)
public function store(Request $request)
{
$data = $request->validate([
'name' => 'required|string|max:255',
'price' => 'required|numeric|min:0',
'image' => 'nullable|image|mimes:jpeg,png,jpg,gif|max:2048',
]);
// COPY-PASTE AGAIN: Generate SKU
$nextId = Product::max('id') + 1;
$data['sku'] = 'P' . str_pad($nextId, 6, '0', STR_PAD_LEFT);
// COPY-PASTE AGAIN: Generate Slug
$data['slug'] = Str::slug($request->name) . '-' . time();
// COPY-PASTE AGAIN: Image Upload
if ($request->hasFile('image')) {
$imageName = time() . '.' . $request->image->extension();
$request->image->move(public_path('images'), $imageName);
$data['image'] = 'images/' . $imageName;
}
$product = Product::create($data);
return response()->json($product, 201);
}2. Duplication in the Background Import Job (ImportProductsJob.php)
foreach ($rows as $row) {
// COPY-PASTE AGAIN: Generate SKU & Slug
$nextId = Product::max('id') + 1;
$sku = 'P' . str_pad($nextId, 6, '0', STR_PAD_LEFT);
$slug = Str::slug($row['product_name']) . '-' . time();
Product::create([
'sku' => $sku,
'slug' => $slug,
'name' => $row['product_name'],
'price' => $row['price'],
]);
}Write once, copy everywhere. This architecture is incredibly fragile. If your boss decides that SKUs must start with PROD- instead of P, you now have to search and update your code in three separate files. If you forget one? Congratulations, you just bought yourself a VIP ticket to a 3 A.M. Slack ping session with your manager. 😂
If we draw a map of this architecture, it becomes painfully obvious how fragile and fragmented our application is becoming:
The Solution: Building ProductObserver
Alright, enough chit-chat like 🙍♀️. Let’s bundle all that manual SKU, Slug, and file uploading code, and move it into one clean home: ProductObserver.php!
1. The Observer — Let’s Build the Hero First!
First, run this artisan command to instantly scaffold our observer pre-linked with our Product model:
php artisan make:observer ProductObserver --model=ProductThis will generate a template inside app/Observers/ProductObserver.php. Let's open it up and place all of our model automation logic inside:
namespace App\Observers;
use App\Models\Product;
use Illuminate\Support\Str;
class ProductObserver
{
/**
* Run automatically BEFORE a new product is saved
*/
public function creating(Product $product): void
{
// Generate a unique SKU automatically
$nextId = Product::max('id') + 1;
$product->sku = 'P' . str_pad($nextId, 6, '0', STR_PAD_LEFT);
// Generate a slug automatically
$product->slug = Str::slug($product->name) . '-' . time();
}
/**
* Run automatically BEFORE an existing product is updated
*/
public function updating(Product $product): void
{
// Regenerate the slug only if the product name changed
if ($product->isDirty('name')) {
$product->slug = Str::slug($product->name) . '-' . time();
}
}
/**
* Run automatically BEFORE saving (Create & Update)
*/
public function saving(Product $product): void
{
// Handle file upload if a new image is sent
if (request()->hasFile('image')) {
// Clean up the old image file if updating
if ($product->exists && $product->isDirty('image') && $product->getOriginal('image')) {
$oldImage = $product->getOriginal('image');
if (file_exists(public_path($oldImage))) {
unlink(public_path($oldImage));
}
}
$image = request()->file('image');
$imageName = time() . '.' . $image->getClientOriginalExtension();
$image->move(public_path('images'), $imageName);
$product->image = 'images/' . $imageName;
}
}
/**
* Run automatically BEFORE a product is deleted
*/
public function deleting(Product $product): void
{
// Clean up the physical image from the server to save space
if ($product->image && file_exists(public_path($product->image))) {
unlink(public_path($product->image));
}
}
}2. The Registration — Making It Official
After creating our ProductObserver, we need to let Laravel know it should watch the Product model. Think of this like updating your relationship status on Facebook 🤣, but Laravel-style. You’ve got two modern ways to make this happen:
Option 1: PHP Attributes (The Modern Laravel Way)
In the latest versions of Laravel, you can register observers directly inside the Model class using PHP Attributes. This is incredibly clean because you don’t even have to open a Service Provider class. Just open your Product.php model and add the attribute:
namespace App\Models;
use App\Observers\ProductObserver;
use Illuminate\Database\Eloquent\Attributes\ObservedBy;
use Illuminate\Database\Eloquent\Model;
#[ObservedBy([ProductObserver::class])] // This line right here is the magic
class Product extends Model
{
// Your model properties and relations...
}Option 2: Manual Registration in AppServiceProvider
If you prefer centralizing your registrations, or if you are working on an older Laravel codebase, you can manually register it inside the boot method of app/Providers/AppServiceProvider.php:
class AppServiceProvider extends ServiceProvider
{
public function boot(): void
{
Product::observe(ProductObserver::class);
}
}3. The Clean Slate — Total Extermination (After)
With our observer registered, let’s look at how clean, readable, and beautiful all our entry points become. Think of this like taking a deep breath after being underwater:
- Web Controller (
ProductController.php)
class ProductController extends Controller
{
public function store(StoreProductRequest $request)
{
Product::create($request->validated());
return redirect()->route('products.index')->with('success', 'Product created successfully.');
}
public function update(UpdateProductRequest $request, Product $product)
{
$product->update($request->validated());
return redirect()->route('products.index')->with('success', 'Product updated successfully.');
}
}- REST API Controller (
API/ProductController.php)
public function store(Request $request)
{
$data = $request->validate([
'name' => 'required|string|max:255',
'price' => 'required|numeric|min:0',
'image' => 'nullable|image|mimes:jpeg,png,jpg,gif|max:2048',
]);
$product = Product::create($data);
return response()->json($product, 201);
}- Background Excel Import (
ImportProductsJob.php)
foreach ($rows as $row) {
Product::create([
'name' => $row['product_name'],
'price' => $row['price'],
]);
}Your controllers are now so thin they could practically slide under a closed door. 😂 No more copying and pasting lines of code like a modern-day digital scribe. Just clean, predictable, stress-free code that does exactly what you expect it to do. 💻
The Concept: Eloquent Lifecycle Events Cheat Sheet
So… what’s the secret behind this magic? 🧙♂️ Instead of copy-pasting code, we can utilize model lifecycle events. Every time a model is loaded, saved, updated, or deleted, Laravel dispatches a lifecycle event in the background.
Here is the complete cheat sheet of all 15 Eloquent lifecycle events, explained simply:
retrieved(After loaded): Fired after a model is successfully loaded from the database.creating(Before insert): Fired before a brand-new model is saved for the first time. (Our hero for generating SKUs and slugs!)created(After insert): Fired after a new model is successfully saved to the database.updating(Before update): Fired before an existing model is updated.updated(After update): Fired after an existing model is successfully updated.saving(Before save): Fired before a model is written to the database (runs on both insert and update).saved(After save): Fired after a model is successfully saved (runs on both insert and update).deleting(Before delete): Fired before a model is deleted. (The perfect place to clean up physical storage files!)deleted(After delete): Fired after a model is successfully deleted.trashed(After soft-delete): Fired after a model is soft-deleted.forceDeleting(Before hard-delete): Fired before a model is permanently deleted.forceDeleted(After hard-delete): Fired after a model is permanently deleted.restoring(Before restore): Fired before a soft-deleted model is restored.restored(After restore): Fired after a soft-deleted model is successfully restored.replicating(During clone): Fired when a model is being cloned using thereplicate()method.
By harnessing these events via our ProductObserver, we establish a clean, centralized middle-man that intercepts model operations. Here is how our refined, DRY architecture flows now:
Final Thoughts: The Single Source of Truth Pattern
Shifting lifecycle logic into Eloquent Observers establishes a robust Single Source of Truth. In our study case, generating SKUs, slugs, and uploading images served as a perfect illustration of how to consolidate fragmented logic. By binding these operations to model events, they are guaranteed to run consistently-whether triggered by a web controller, API controller, background job, or database seeder.
Quick Decision Guide:
- Use Observers for: Centralized automations (e.g., generating default values), disk cleanups (e.g., deleting orphaned images when a record is deleted), and simple side effects (e.g., firing a welcome email or logging activity).
- Avoid Observers for: Complex or heavy external API calls that can slow down your database transactions. Keep database-adjacent logic database-adjacent!
Next time you find yourself copy-pasting lifecycle logic across multiple controllers, stop and ask yourself: “Can I move this to an observer?” Keep your controllers slim, your code DRY, and let Laravel’s model events do the heavy lifting!
And That’s a Wrap!
Cool, right? Almost magical. With Eloquent observers, you can centralize all your model lifecycles in one clean home-leaving your controllers absolutely pristine.
So instead of spending your precious coding hours copy-pasting the same logic across different files, you can finally focus on more critical engineering features-like, you know, building a Slack bot that automatically praises your manager’s new haircut (kidding… 😅).
