See rates for the top Laravel developers in Latin America

Modelling Busines Processes in Laravel

Published on by

Modelling Busines Processes in Laravel image

As developers, we often map business processes to digital processes, from sending an email to something quite complex. Let's look at how to take a more complicated process and write clean and elegant code.

It all starts with a workflow. I tweeted about writing this tutorial to see if there would be any feedback on business processes people would find helpful - I only really got one response, though.

So with that in mind, let's look at the Order/Shipping process, something with enough moving parts to get the idea across - but I won't go into too much detail from a domain logic perspective.

Imagine you run an online merch store, have an online shop, and use a dropshipping service to send merch out on demand when an order is placed. We need to think about what the business process might look like without any digital help - this allows us to understand the business and its needs.

An item is requested (we are using a print-on-demand service, so stock isn't an issue). We take the customers' details. We create an order for this new customer. We accept payment for this order. We confirm the order and payment to the customer. We then place our order with the print-on-demand service.

The print-on-demand service will periodically update us on the order status, which we can update our customers, but this would be a different business process. Let's look at the order process first and imagine this was all done inline in one controller. It would get quite complicated to manage or change.

class PlaceOrderController
{
public function __invoke(PlaceOrderRequest $request): RedirectResponse
{
// Create our customer record.
$customer = Customer::query()->create([]);
 
// Create an order for our customer.
$order = $customer->orders()->create([]);
 
try {
// Use a payment library to take payment.
$payment = Stripe::charge($customer)->for($order);
} catch (Throwable $exception) {
// Handle the exception to let the customer know payment failed.
}
 
// Confirm the order and payment with the customer.
Mail::to($customer->email)->send(new OrderProcessed($customer, $order, $payment));
 
// Send the order to the Print-On-Demand service
MerchStore::create($order)->for($customer);
 
Session::put('status', 'Your order has been placed.');
 
return redirect()->back();
}
}

So if we walk through this code, we see that we create a user and order - then accept the payment and send an email. Finally, we add a status message to the session and redirect the customer.

So we write to the database twice, talk to the payment API, send an email, and finally, write to the session and redirect. It is quite a lot in one synchronous thread to handle, with a lot of potential for things to break. The logical step here is to move this to a background job so that we have a level of fault tolerance.

class PlaceOrderController
{
public function __invoke(PlaceOrderRequest $request): RedirectResponse
{
// Create our customer record.
$customer = Customer::query()->create([]);
 
dispatch(new PlaceOrder($customer, $request));
 
Session::put('status', 'Your order is being processed.');
 
return redirect()->back();
}
}

We have cleaned up our controller a lot - however, all we have done is move the problem to a background process. While moving this to a background process is the right way to handle this, we need to approach this a lot differently.

Firstly, we want to first or create the customer - in case they have made an order before.

class PlaceOrderController
{
public function __invoke(PlaceOrderRequest $request): RedirectResponse
{
// Create our customer record.
$customer = Customer::query()->firstOrCreate([], []);
 
dispatch(new PlaceOrder($customer, $request));
 
Session::put('status', 'Your order is being processed.');
 
return redirect()->back();
}
}

Our next step is to move the creation of a customer to a shared class - this is one of many times we would want to create or get a customer record.

class PlaceOrderController
{
public function __construct(
private readonly FirstOrCreateCustomer $action,
) {}
 
public function __invoke(PlaceOrderRequest $request): RedirectResponse
{
// Create our customer record.
$customer = $this->action->handle([]);
 
dispatch(new PlaceOrder($customer, $request));
 
Session::put('status', 'Your order is being processed.');
 
return redirect()->back();
}
}

Let's look at the background process code if we moved it directly there.

class PlaceOrder implements ShouldQueue
{
use Dispatchable;
use InteractsWithQueue;
use Queueable;
use SerializesModels;
 
public function _construct(
public readonly Customer $customer,
public readonly Request $request,
) {}
 
public function handle(): void
{
// Create an order for our customer.
$order = $this->customer->orders()->create([]);
 
try {
// Use a payment library to take payment.
$payment = Stripe::charge($this->customer)->for($order);
} catch (Throwable $exception) {
// Handle the exception to let the customer know payment failed.
}
 
// Confirm the order and payment with the customer.
Mail::to($this->customer->email)
->send(new OrderProcessed($this->customer, $order, $payment));
 
// Send the order to the Print-On-Demand service
MerchStore::create($order)->for($this->customer);
}
}

Not too bad, but - what if a step fails and we retry the job? We will end up redoing parts of this process again and again when not needed. We should first look to create the order within a database transaction.

class CreateOrderForCustomer
{
public function handle(Customer $customer, data $payload): Model
{
return DB::transaction(
callback: static fn () => $customer->orders()->create(
attributes: $payload,
),
);
}
}

Now we can update our background process to implement this new command.

class PlaceOrder implements ShouldQueue
{
use Dispatchable;
use InteractsWithQueue;
use Queueable;
use SerializesModels;
 
public function _construct(
public readonly Customer $customer,
public readonly Request $request,
) {}
 
public function handle(CreateOrderForCustomer $command): void
{
// Create an order for our customer.
$order = $command->handle(
customer: $customer,
payload: $this->request->only([]),
);
 
try {
// Use a payment library to take payment.
$payment = Stripe::charge($this->customer)->for($order);
} catch (Throwable $exception) {
// Handle the exception to let the customer know payment failed.
}
 
// Confirm the order and payment with the customer.
Mail::to($this->customer->email)
->send(new OrderProcessed($this->customer, $order, $payment));
 
// Send the order to the Print-On-Demand service
MerchStore::create($order)->for($this->customer);
}
}

This approach works well. However, it isn't ideal, and you do not have much visibility at any point. We could model this differently so that we are modeling our business process instead of splitting it out into parts.

It all starts with the Pipeline facade, enabling us to build this process correctly. We will still want to create our customer in the controller, but we will handle the rest of the process within the background job using a business process.

To begin with, we will need an abstract class that our business process classes can extend to minimize code duplication.

abstract class AbstractProcess
{
public array $tasks;
 
public function handle(object $payload): mixed
{
return Pipeline::send(
passable: $payload,
)->through(
pipes: $this->tasks,
)->thenReturn();
}
}

Our business process class will have many associated tasks, which we declare in the implementation. Then our abstract process will take the passed-on payload and send it through these tasks - eventually returning. Unfortunately, I can't think of a nice way to return an actual type instead of mixed, but sometimes we have to compromise...

class PlaceNewOrderForCustomer extends AbstractProcess
{
public array $tasks = [
CreateNewOrderRecord::class,
ChargeCustomerForOrder::class,
SendConfirmationEmail::class,
SendOrderToStore::class,
];
}

As you can see, this is super clean to look at and works well. These tasks can be reused in other business processes where it makes sense.

class PlaceOrder implements ShouldQueue
{
use Dispatchable;
use InteractsWithQueue;
use Queueable;
use SerializesModels;
 
public function _construct(
public readonly Customer $customer,
public readonly Request $request,
) {}
 
public function handle(PlaceNewOrderForCustomer $process): void
{
try {
$process->handle(
payload: new NewOrderForCustomer(
customer: $this->customer->getKey(),
orderPayload: $this->request->only([]),
),
);
} catch (Throwable $exception) {
// Handle the potential exceptions that could occur.
}
}
}

Our background process now tries to handle the business process, and if any exceptions happen, we can fail and retry the process later on. As Laravel will use its DI container to pass through what you need into the jobs handle method, we can pass our process class into this method and let Laravel resolve this for us.

class CreateNewOrderRecord
{
public function __invoke(object $payload, Closure $next): mixed
{
$payload->order = DB::transaction(
callable: static fn () => Order::query()->create(
attributes: [
$payload->orderPayload,
'customer_id' $payload->customer,
],
),
);
 
return $next($payload);
}
}

Our business process tasks are invokable classes that get passed the "traveller", which is the payload we want to pass through, and a Closure which is the next task in the pipeline. This is similar to how the middleware functionality works in Laravel, where we can chain on as many as we need, and they are just sequentially called.

The payload we pass in can be a simple PHP object we can use to build as it goes through a pipeline, extending it at each step, allowing the next task in the pipeline to access any information it needs without running a database query.

Using this approach, we can break down our business processes that aren't digital and make digital representations of them. Chaining them together in this way adds automation where we need it. It is quite a simple approach, really, but it is very powerful.

Have you found a nice way to handle business processes in Laravel? What did you do? Let us know on twitter!

Steve McDougall photo

Educator and Content creator, freelance consultant, API evangelist

Cube

Laravel Newsletter

Join 40k+ other developers and never miss out on new tips, tutorials, and more.

image
Laravel Code Review

Get expert guidance in a few days with a Laravel code review

Visit Laravel Code Review
Curotec logo

Curotec

World class Laravel experts with GenAI dev skills. LATAM-based, embedded engineers that ship fast, communicate clearly, and elevate your product. No bloat, no BS.

Curotec
Bacancy logo

Bacancy

Supercharge your project with a seasoned Laravel developer with 4-6 years of experience for just $3200/month. Get 160 hours of dedicated expertise & a risk-free 15-day trial. Schedule a call now!

Bacancy
Tinkerwell logo

Tinkerwell

The must-have code runner for Laravel developers. Tinker with AI, autocompletion and instant feedback on local and production environments.

Tinkerwell
Cut PHP Code Review Time & Bugs into Half with CodeRabbit logo

Cut PHP Code Review Time & Bugs into Half with CodeRabbit

CodeRabbit is an AI-powered code review tool that specializes in PHP and Laravel, running PHPStan and offering automated PR analysis, security checks, and custom review features while remaining free for open-source projects.

Cut PHP Code Review Time & Bugs into Half with CodeRabbit
Get expert guidance in a few days with a Laravel code review logo

Get expert guidance in a few days with a Laravel code review

Expert code review! Get clear, practical feedback from two Laravel devs with 10+ years of experience helping teams build better apps.

Get expert guidance in a few days with a Laravel code review
Kirschbaum logo

Kirschbaum

Providing innovation and stability to ensure your web application succeeds.

Kirschbaum
Shift logo

Shift

Running an old Laravel version? Instant, automated Laravel upgrades and code modernization to keep your applications fresh.

Shift
Harpoon: Next generation time tracking and invoicing logo

Harpoon: Next generation time tracking and invoicing

The next generation time-tracking and billing software that helps your agency plan and forecast a profitable future.

Harpoon: Next generation time tracking and invoicing
Lucky Media logo

Lucky Media

Get Lucky Now - the ideal choice for Laravel Development, with over a decade of experience!

Lucky Media
Lunar: Laravel E-Commerce logo

Lunar: Laravel E-Commerce

E-Commerce for Laravel. An open-source package that brings the power of modern headless e-commerce functionality to Laravel.

Lunar: Laravel E-Commerce
SaaSykit: Laravel SaaS Starter Kit logo

SaaSykit: Laravel SaaS Starter Kit

SaaSykit is a Multi-tenant Laravel SaaS Starter Kit that comes with all features required to run a modern SaaS. Payments, Beautiful Checkout, Admin Panel, User dashboard, Auth, Ready Components, Stats, Blog, Docs and more.

SaaSykit: Laravel SaaS Starter Kit

The latest

View all →
A new beta of Laravel Wayfinder just dropped image

A new beta of Laravel Wayfinder just dropped

Read article
Ben Bjurstrom: Laravel is the best Vibecoding stack for 2026 image

Ben Bjurstrom: Laravel is the best Vibecoding stack for 2026

Read article
Laravel Altitude - Opinionated Claude Code agents and commands for TALL stack development image

Laravel Altitude - Opinionated Claude Code agents and commands for TALL stack development

Read article
JSON:API Resource in Laravel 12.45 image

JSON:API Resource in Laravel 12.45

Read article
Caching With MongoDB for Faster Laravel Apps image

Caching With MongoDB for Faster Laravel Apps

Read article
Laravel 12.44 Adds HTTP Client afterResponse() Callbacks image

Laravel 12.44 Adds HTTP Client afterResponse() Callbacks

Read article