Skip to content
Atomic Laravel Tips and Tricks Laravel

Atomic Laravel: Build Laravel Apps Where Every Piece Does One Job

Muhammad Raheel

Muhammad Raheel

November 9th, 2026 · 79 min read

Atomic Laravel: Build Laravel Apps Where Every Piece Does One Job

Are you a Laravel developer? Then you've seen this.

You open a controller and it's 300 lines long. Validation, queries, emails, if-else blocks, all in one method. You open the Blade file and it's using $data, $items and $row. What's inside them? No idea. You have to go back to the controller to find out.

Then the client says, "Can this table refresh without reloading the page?" And suddenly you're writing a JSON API, adding a JavaScript framework, setting up a build step, and building the same table a second time in JavaScript.

I've been there. Many times. In my own projects and in client projects.

Over the years I fixed these problems one by one. First I wrote AQC for queries. Then ViewModels and DTOs for views. Then the Blade Partial API for partial page updates. Then events and observers for everything that happens after a save.

One day I looked at all of them together and realised something. They all follow the same rule.

That's when I gave it a name: Atomic Laravel.

Grab a coffee. This is a long one.

So What Is Atomic Laravel?

Here's the rule:

Every piece of your app does one job, and says clearly what it needs.

That's it. One class, one job. One file, one purpose.

And you don't need a package for it. It's plain Laravel, plain HTML, HTMX and a small JavaScript library. These are the tools:

Tool Its one job
FormRequest Checks the input
Controller Connects everything
AQC Runs one query
DTO Lists the fields a view needs
ViewModel Holds everything a page needs
Blade partial Shows one piece of the page
jsRibbon Makes that piece interactive
Observers and events Handle what happens after a save

Instead of explaining each one on its own, let's build something. Say you're building a products page for an e-commerce app. It has a product table, a category filter, and a form to add or edit products.

We'll build it one step at a time.

Step 1: FormRequest Checks the Door

Every request starts here. The FormRequest asks two questions:

  1. Is this user allowed to do this?
  2. Is the input valid?

If the answer to either is no, Laravel sends the user back. The controller never even runs.

class SaveProductRequest extends FormRequest
{
    public function authorize(): bool
    {
        return $this->user()->can('manage-products');
    }

    public function rules(): array
    {
        return [
            'name'        => 'required|string|max:255',
            'price'       => 'required|numeric|min:0',
            'category_id' => 'required|exists:categories,id',
        ];
    }
}

Notice what's missing. No queries. No business logic. Just checks.

I used to put business logic inside FormRequest too. It kept controllers clean, but there was a problem. A queue job or a command can't use a FormRequest. So that logic was stuck in HTTP. Now it lives in AQC, and FormRequest only checks the door.

Step 2: The Controller Calls the Shots

The controller is the manager. It doesn't do the work itself. It tells others what to do.

class ProductController extends Controller
{
    public function index(GetProductsRequest $request)
    {
        $products = (new GetProducts())->handle([
            ...$request->validated(),
            'columns' => SimpleProduct::columns(),
        ]);

        $categories = Cache::remember('categories', 3600,
            fn () => (new GetCategories())->handle(['columns' => SimpleCategory::columns()]));

        return view('products.index', [
            'model' => new ProductsViewModel($products, $categories),
        ]);
    }

    public function save(SaveProductRequest $request, int $id = 0)
    {
        (new SaveProduct())->handle([...$request->validated(), 'id' => $id]);

        return redirect()->route('products.index')->with('success', 'Product saved.');
    }
}

Read it top to bottom. Get the products. Get the categories. Build the page. Done.

Three things to notice:

  • Only the controller calls AQC. Not the ViewModel, not Blade, not an observer. Want to know where a query runs? Open the controller. Always.
  • Caching happens here. The categories rarely change, so the controller caches them. AQC itself never caches.
  • One form, one save. There's no separate create and update. An id of 0 means new. I explained this in Laravel CRUD Like a Pro.

Step 3: AQC Fetches the Data

Now the controller asks AQC for products. AQC means one class per query. You give it an array. It gives you data.

namespace App\AQC\Product;

class GetProducts
{
    public function handle(array $params = [])
    {
        $query = Product::select($params['columns'] ?? '*')->latest('id');

        if (!empty($params['category_id'])) {
            $query->where('category_id', $params['category_id']);
        }

        if (!empty($params['search'])) {
            $query->where('name', 'like', '%' . $params['search'] . '%');
        }

        return $query->paginate(Product::PAGINATE);
    }
}

See how it works? Each key in the array switches on one part of the query.

  • Pass category_id? It filters by category.
  • Pass search? It filters by name.
  • Pass nothing? You get all products.

The same class handles every combination. No getProductsByCategory(), no getProductsByCategoryAndSearch(). Just one class.

Every resource gets the same five classes: GetProducts, GetProduct, SaveProduct, UpdateProduct and DeleteProduct. Nothing more.

A few things AQC never does:

  • It never takes DTOs. Just an array. Here's why.
  • It never returns DTOs. It returns models, collections or paginators.
  • It never touches HTTP. No request(), no session. That's why a web route, an API, a job or a command can all use the same class.

Worried the class will get too big? I wrote about that here.

Step 4: DTOs and ViewModels Give Blade a Contract

We have the products. Now, how do we hand them to Blade?

The usual way is to pass a few loose variables and hope for the best. But then Blade doesn't know what it's getting. A typo like $product->nmae just prints nothing. No error. You find out when a customer complains.

So we give Blade a contract instead.

The DTO

A DTO is a small class that lists exactly the fields a view needs. Nothing more.

class SimpleProduct extends BaseDTO
{
    public int $id;
    public string $name;
    public float $price;
    public int $stock;
}

BaseDTO does two smart things:

  • Its columns() method returns these property names. Remember 'columns' => SimpleProduct::columns() in the controller? That's why the query only selects these four columns.
  • It throws an error if you read a property that doesn't exist. No more silent blanks.

The ViewModel

A ViewModel holds everything one page needs. It takes what AQC returned and turns it into DTOs.

class ProductsViewModel
{
    /** @var SimpleProduct[] */
    public array $products;

    /** @var SimpleCategory[] */
    public array $categories;

    public function __construct($products, $categories)
    {
        $this->products   = DTOMapper::map($products, SimpleProduct::class);
        $this->categories = DTOMapper::map($categories, SimpleCategory::class);
    }
}

Notice it doesn't call AQC. It only receives data from the controller.

The Blade side

Every Blade file gets one variable called $model, and says what type it is on the first line:

@php /** @var \App\ViewModels\ProductsViewModel $model */ @endphp

<h1>Products ({{ count($model->products) }})</h1>

Now two things happen:

  • Your editor helps you. With PHP Tools by DevSense, type $model-> and you get autocomplete.
  • Laravel checks it. My TypedViewFactory reads that @var line. Pass the wrong type and you get a clear error instead of a broken page.

No more guessing what's inside $data.

Step 5: One Blade Partial, Loaded Two Ways

This is my favourite part.

Remember the client asking for the table to refresh without a page reload? Most developers reach for a JSON API here. I don't. I return HTML instead.

The trick is to split the table into two parts: a container and a partial.

The container

The container is a plain div on the page. It carries the HTMX attributes and it never gets replaced.

<div id="product-list"
     hx-get="/products/partials/products-table"
     hx-trigger="refresh-products from:body"
     hx-swap="innerHTML">
    @include('products.partials.products-table', ['model' => $model->products])
</div>

See the @include inside? That's the partial.

The partial

The partial is the actual table. Here's the beautiful part: the same file is used twice.

  • First page load: it comes in through @include. The user and Google see the full page right away.
  • Every refresh after that: HTMX calls /products/partials/products-table. Laravel renders the same partial and sends back just that HTML. HTMX drops it inside the container.

One file. Two ways to load it. You never write the same table twice.

That's also why the swap is always innerHTML. Only the inside changes. The container and its hx-* attributes stay put, ready for the next refresh.

How does Laravel know what to load?

The URL is the view path. /products/partials/products-table points to resources/views/products/partials/products-table.blade.php.

One catch-all route sends these requests to a resolver controller. It reads the URL and figures out the rest:

  1. products tells it the model is Product.
  2. The file name starts with products-, so it needs a list. (product- would mean one record.)
  3. The partial's @var line names the DTO, so it knows which columns to select.
  4. It calls GetProducts, renders the partial and returns the HTML.

No extra route per partial. No extra controller per partial.

And since the resolver is a controller, our rule still holds: only controllers call AQC. It also uses web routes, so sessions, auth, @can and @error all work as usual.

Step 6: jsRibbon Brings It to Life

HTML from the server is great. But sometimes you need a little interactivity in the browser too. Live filtering, toggles, counters.

That's where jsRibbon comes in. It's a small library I built. You add data-bind attributes to your HTML and it becomes reactive.

Here's our partial with a jsRibbon filter inside it:

{{-- products/partials/products-table.blade.php --}}
@php /** @var \App\DTO\Product\SimpleProduct[] $model */ @endphp

<input value="" data-bind="value:search" placeholder="Filter this page">
<p>Filtering for: <span data-bind="text:search"></span></p>

<table>
    @foreach ($model as $product)
        @include('products.partials.product-row', ['model' => $product])
    @endforeach
</table>

Now here's the question you're probably asking:

"When HTMX swaps in new HTML, do I have to set up jsRibbon again?"

No.

jsRibbon is loaded once on the page and watches for new HTML. Whenever new markup appears, whether from @include or from an HTMX swap, it wakes up those bindings on its own. No re-init code. No hooks.

So each part has one owner:

  • The container owns the HTMX attributes.
  • The partial owns the HTML and the jsRibbon bindings.
  • The page loads jsRibbon once.

One thing to keep in mind: the server is always the source of truth. When a partial refreshes, it starts again from fresh server HTML. If a form sits inside a partial, it posts to the server and gets the same partial back, with @error messages already filled in.

Step 7: Saving Data With Observers and Events

Reading data is done. Now let's save something.

Say a customer places an order. What needs to happen?

  • Set the order status to pending.
  • Calculate the discount price.
  • Email the customer.
  • Notify the admin panel.

Would you put all of this in the controller? No. In the AQC class? Also no.

Here's how we split it.

AQC just saves

class SaveOrder
{
    public function handle(array $params)
    {
        $order = Order::findOrNew($params['id'] ?? 0);
        $order->fill(Arr::except($params, 'id'))->save();

        return $order;
    }
}

That's the whole class. One plain save.

The observer handles the model's own stuff

The status and the discount belong to the order itself. So they go in an observer.

#[ObservedBy([OrderObserver::class])]
class Order extends Model {}

class OrderObserver
{
    public function creating(Order $order): void
    {
        $order->status = OrderStatus::Pending;

        if ($order->discount_percentage) {
            $order->discount_price = $order->price * (1 - $order->discount_percentage / 100);
        }
    }

    public function created(Order $order): void
    {
        event(new OrderCreated($order));
    }
}

creating() runs before the order is saved, so it sets the fields. created() runs after, so it announces: "An order was created."

Listeners handle everything else

The emails and notifications don't belong to the order. They're reactions to it. So they become listeners, one job each.

protected $listen = [
    OrderCreated::class => [
        SendOrderConfirmationEmail::class,
        NotifyAdminPanel::class,
    ],
];

I register them by hand, so I can see the whole chain in one place. Events are named in the past tense (OrderCreated). Listeners are named after what they do (SendOrderConfirmationEmail).

Now think about what happens when the client says, "Also send an SMS." You add one listener. That's it. The controller doesn't change. The AQC class doesn't change.

And if you're importing 10,000 old orders and don't want 10,000 emails? Use saveQuietly(). It skips the observer.

The Whole Flow in One Picture

Let's put all seven steps together. Reading and saving take the same first three steps, then go their own way after the controller.

image.png
image.png

On the left, data flows down into a typed partial that jsRibbon makes interactive. On the right, AQC saves once, and the observer and listeners take care of the rest.

The Rules

If you remember nothing else, remember these.

  1. FormRequest only checks. No business logic.
  2. Only controllers call AQC. Never ViewModels, Blade or observers.
  3. One AQC class, one query. One handle(array $params) method. One array in.
  4. AQC never returns DTOs. The caller turns results into DTOs.
  5. Cache in the caller, never inside AQC.
  6. Every Blade file gets $model, with its type on the first line.
  7. One ViewModel per page. One DTO per partial.
  8. Unknown properties throw. No silent blanks.
  9. One form for create and edit. One save action.
  10. Containers own HTMX. Partials own HTML and jsRibbon.
  11. Observers set the model's own fields. Listeners do everything else.
  12. Events in the past tense. Listeners named after their job.
  13. No magic strings in conditions. Use constants or enums.
  14. Give long conditions a name. $isShippable reads better than three &&s.

This isn't overkill. It's discipline.

Is It Perfect? No.

I won't pretend it's free. Here's what it costs.

  • Type errors show up when the page renders, not before. TypeScript would catch them at build time. A simple test that opens each page closes most of that gap.
  • DTOs aren't Eloquent models. No accessors, no relations, no helpers. Do your calculations before mapping. And DTO property names must match your column names.
  • A refresh resets the partial. Anything the user typed but didn't submit is gone after a swap.
  • The catch-all route needs a guard. Protect it with middleware, and only allow the resources you expect.
  • More files. Five AQC classes, a FormRequest per action, a DTO per view. Each file is tiny and easy to find, but there are lots of them.
  • Don't rewrite an old project in one go. Start with new modules and move over slowly.

Final Thoughts

Atomic Laravel isn't a framework. It's not a package you install. It's a way of building with what Laravel already gives you.

  • FormRequest checks.
  • The controller connects.
  • AQC queries.
  • DTOs and ViewModels describe.
  • Partials render.
  • jsRibbon reacts.
  • Observers and listeners follow up.

Each piece does one job. That's the whole idea.

You get partial page updates without Livewire. Exact data without GraphQL. A fast, interactive UI without a SPA. All with plain Laravel. And because AQC sits at the centre of everything, the same query works for your web pages, your API, your jobs and your commands.

This is how I build every new Laravel project now. It's also the rulebook my AI agents follow when they write code for me.

In the next article, I'll compare Atomic Laravel with Remix. Remix 3 now updates parts of the page with server-rendered "frames", which is surprisingly close to what we did in Step 5. I'll show where they match, where Remix is ahead, and how to get the same result without leaving Laravel.

Stop dumping everything in controllers. Start building atomic.

Found this useful?

Support the work that goes into these posts.

Support
Comments


Comment created and will be displayed once approved.