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);
    }
}
                            

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);
    }
}
                            

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;
    }
}
                            

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
}
                            

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);
    }
} 
                            

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);
    }
}
                            

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);
    }
}
                            

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));
    }
}
                            

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.

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.

 Raheel Shan | Support my work
Raheel Shan | Support my work
Support my work
raheelshan.com
Comments


Comment created and will be displayed once approved.