DEV Community

Cover image for Distributed Business Logic with Laravel Observers: Let Models Mind Their Own Business
Raheel Shan
Raheel Shan

Posted on • Originally published at raheelshan.com

Distributed Business Logic with Laravel Observers: Let Models Mind Their Own Business

Writing business logic in a controller or service is easy, but just because it is easy doesn’t mean it’s clean.If your models are dumb and all the brain lives in controllers, you should reconsider your approach.Imagine a scenario where an order is placed in an e-commerce system. You need to do the following.

  • Place the order
  • Calculate costing, pricing and discounts before saving
  • Generate order number before saving
  • Send emails to customer and store admin
  • Produce notification sound in admin panel to get attention

This is a typical example where you need to do multiple steps to complete a process. Unless you do all these steps, the operation is considered incomplete. But to achieve this, many people use a single class and cram all the logic into it to execute this whole process. This pollutes the class with much code. Instead we can take advantage of Laravel Observers to place the logic where it belongs.Let’s explore how we can take advantage of Laravel Observers, a powerful, underrated pattern in Laravel, and write distributed business logic.

1. Save Data

Let's say a user is placing an order and we need to save it in the database. The most useful approach is to use the create() method. I would use the AQC design pattern for clean code.

<?php

namespace App\AQC\Order;
use App\Models\Order;

class CreateOrder
{
    public static function handle($params)
    {
        Order::create($params);
    }
}
Enter fullscreen mode Exit fullscreen mode

This is the data we want to save as is. No change in user-provided data. Simple, right?

2. Do Calculations

Let’s say we want to do some calculations for some columns based on the data posted by the user when saving. Where would you write the logic? Before the create() method?

<?php

namespace App\AQC\Order;
use App\Models\Order;
use App\Constants\OrderStatus;

class CreateOrder
{
    public static function handle($params)
    {
        $data = $params;

        if ($data["discount_percentage"]) {
            $data["discount_price"] = $data["price"] * (1 - $data["discount_percentage"] / 100);
        }

        $data['status'] = OrderStatus::Pending;

        Order::create($data);
    }
}
Enter fullscreen mode Exit fullscreen mode

But I would rather use Observer here. If you don’t know what Observers are, you can read here.

https://laravel.com/docs/12.x/eloquent#observers

<?php

namespace App\Observers;
use App\Constants\OrderStatus;

class OrderObserver
{
    public function creating(Order $order)
    {
        if ($order->discount_percentage) {
            $order->discount_price = $order->price * (1 - $order->discount_percentage / 100);
        }

        $order->status = OrderStatus::Pending;
    }
}
Enter fullscreen mode Exit fullscreen mode

This method will be defined in the OrderObserver class, and the observer class will be observing the Order model.

<?php

namespace App\Models;

use App\Observers\OrderObserver;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Attributes\ObservedBy;

#[ObservedBy([OrderObserver::class])]
class Order extends Model
{
    // other code
}
Enter fullscreen mode Exit fullscreen mode

If you want to avoid triggering of Observable events in any case, you can use quite methods like saveQuietly().

3. Generate data from Internals

Let’s say the order needs an incremental order number, so we need a function that should generate the order number by querying how many orders have already been saved. Get the latest count and generate a new order number. What would you do?

<?php

namespace App\AQC\Order;
use App\Models\Order;

class CreateOrder
{
    public static function handle($params)
    {
        $data = $params;

        if ($data["discount_percentage"]) {
            $data["discount_price"] = $data["price"] * (1 - $data["discount_percentage"] / 100);
        }

        $data['order_number'] = self::generateOrderNumber();

        Order::create($data);
    }

    private static function generateOrderNumber()
    {
        $count = Order::count();
        return 'ORDER-' . str_pad($count + 1, 5, '0', STR_PAD_LEFT);
    }
} 
Enter fullscreen mode Exit fullscreen mode

Again, I would move this logic of internal query call to Observer.

<?php

namespace App\Observers;
use App\Models\Order;
use App\Constants\OrderStatus;

class OrderObserver
{
    public function creating(Order $order)
    {
        if ($order->discount_percentage) {
            $order->discount_price = $order->price * (1 - $order->discount_percentage / 100);
        }

        $order->status = OrderStatus::Pending;

        $count = Order::count();
        $order->order_number = 'ORDER-' . str_pad($count + 1, 5, '0', STR_PAD_LEFT);
    }
}
Enter fullscreen mode Exit fullscreen mode

This seems the correct location for this kind of work. We have moved our logic into the creating() method, telling Laravel to do this additional work before saving data to the database.

4. Trigger Events

Let’s say we want to notify the user that his order has been placed via email. Where would you write the call to trigger event? In controller?

<?php

namespace App\AQC\Order;
use App\Models\Order;
use App\Events\NotifyOrderCreation;

class CreateOrder
{
    public static function handle($params)
    {
        $data = $params;

        if ($data["discount_percentage"]) {
            $data["discount_price"] = $data["price"] * (1 - $data["discount_percentage"] / 100);
        }

        $data['order_number'] = self::generateOrderNumber();

        $order = Order::create($data);

        event(new NotifyOrderCreation($order));
    }

    private static function generateOrderNumber()
    {
        $count = Order::count();
        return 'SKU' . str_pad($count + 1, 5, '0', STR_PAD_LEFT);
    }
}
Enter fullscreen mode Exit fullscreen mode

Again, I will write this event-triggering logic to Observer but in the created() method this time because at this point Order has been saved.

<?php

namespace App\Observers;
use App\Models\Order;
use App\Events\NotifyOrderCreation;
use App\Constants\OrderStatus;

class OrderObserver
{
    public function creating(Order $order)
    {
        if ($order->discount_percentage) {
            $order->discount_price = $order->price * (1 - $order->discount_percentage / 100);
        }

        $order->status = OrderStatus::Pending;

        $count = Order::count();
        $order->order_number = 'ORDER-' . str_pad($count + 1, 5, '0', STR_PAD_LEFT);
    }

    public function created(Order $order)
    {
        event(new NotifyOrderCreation($order));
    }
}
Enter fullscreen mode Exit fullscreen mode

Summary

Instead of writing everything into a single class, we have now distributed the code chunks to different parts of observers, which seems the right place. Once we are done, we don’t have to worry about how we complete these steps if we place orders from different APIs.

Business logic doesn’t belong in controllers and services, and models shouldn’t be dumb shells waiting for orders.

The Laravel Observers feature gives us the feasibility to perform actions before and after performing any action on the database. It works similarly to events in a database but gives more control.

By using Observers, you let your models take responsibility for their own behavior. Calculations, data generation, conditional logic, and event firing — these are all things the model should handle itself, not some controller or service.

This separation keeps your architecture lean and expressive. AQCs handle intent. Observers handle behavior. Controllers stay clean.

Let the model own its business. That’s how you write sustainable Laravel.


If you found this post helpful, consider supporting my work — it means a lot.

Support my work

Top comments (0)