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

Technical writer at Laravel News, Developer Advocate at Treblle. API specialist, veteran PHP/Laravel engineer. YouTube livestreamer.

Cube

Laravel Newsletter

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

Laravel Forge logo

Laravel Forge

Easily create and manage your servers and deploy your Laravel applications in seconds.

Laravel Forge
Tinkerwell logo

Tinkerwell

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

Tinkerwell
No Compromises logo

No Compromises

Joel and Aaron, the two seasoned devs from the No Compromises podcast, are now available to hire for your Laravel project. ⬧ Flat rate of $7500/mo. ⬧ No lengthy sales process. ⬧ No contracts. ⬧ 100% money back guarantee.

No Compromises
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
Bacancy logo

Bacancy

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

Bacancy
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
LaraJobs logo

LaraJobs

The official Laravel job board

LaraJobs
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
Supercharge Your SaaS Development with FilamentFlow: The Ultimate Laravel Filament Boilerplate logo

Supercharge Your SaaS Development with FilamentFlow: The Ultimate Laravel Filament Boilerplate

Build your SaaS application in hours. Out-of-the-box multi-tenancy and seamless Stripe integration. Supports subscriptions and one-time purchases, allowing you to focus on building and creating without repetitive setup tasks.

Supercharge Your SaaS Development with FilamentFlow: The Ultimate Laravel Filament Boilerplate
Rector logo

Rector

Your partner for seamless Laravel upgrades, cutting costs, and accelerating innovation for successful companies

Rector
MongoDB logo

MongoDB

Enhance your PHP applications with the powerful integration of MongoDB and Laravel, empowering developers to build applications with ease and efficiency. Support transactional, search, analytics and mobile use cases while using the familiar Eloquent APIs. Discover how MongoDB's flexible, modern database can transform your Laravel applications.

MongoDB

The latest

View all →
Asymmetric Property Visibility in PHP 8.4 image

Asymmetric Property Visibility in PHP 8.4

Read article
Access Laravel Pulse Data as a JSON API image

Access Laravel Pulse Data as a JSON API

Read article
Laravel Forge adds Statamic Integration image

Laravel Forge adds Statamic Integration

Read article
Transform Data into Type-safe DTOs with this PHP Package image

Transform Data into Type-safe DTOs with this PHP Package

Read article
PHPxWorld - The resurgence of PHP meet-ups with Chris Morrell image

PHPxWorld - The resurgence of PHP meet-ups with Chris Morrell

Read article
Herd Executable Support and Pest 3 Mutation Testing in PhpStorm 2024.3 image

Herd Executable Support and Pest 3 Mutation Testing in PhpStorm 2024.3

Read article