As applications grow and evolve, even sending an email to a user can become a complex business process that spans days or weeks.
In addition, many Laravel apps start as a monolith, evolve to a distributed monolith and then finally transition to lightweight microservices, highly decoupled and deployed independently.
Microservices make it easier for Laravel apps to scale but they bring with them a new set of challenges. How do microservices coordinate with each other to complete a business process?
The two commonly described solutions are orchestration and choreography.
With orchestration, the business process is captured explicitly in a single place. It can be easily monitored and retried if there is a failure. And each service is independent from each other. However, the downside is that the orchestrator tightly couples to each of the services and presents a single point of failure.
With choreography, the services are loosely coupled and therefore more scalable, can change independently and are more fault tolerant. But the downside is that the logic for the business process, what we care most about, is now scattered throughout the system. Our business process has become an emergent property of the system, no longer explicit.
Laravel Workflow
Workflows combine the benefits of both of these. We can write our logic in a central orchestration but keep things loosely coupled and fault tolerant. I have written a package for this called Laravel Workflow.
Laravel Workflow is powered by standard queued jobs and supports any queue library that Laravel does, Redis, SQS, etc. Under the hood, Laravel Workflow has two types of jobs, workflows and activities.
Activities
An activity is a fairly normal job. It has a few extra features to make it more fault tolerant. By default, activities will retry forever, until the transient error resolves itself, or someone deploys a code fix, restores a database table or fixes whatever is the root cause. But don't worry, activities have an exponential backoff. So you won't be hammering your queue while you frantically deploy a fix!
<?php
namespace App\Workflows\VerifyEmail;
use App\Mail\VerifyEmail;
use Illuminate\Support\Facades\Mail;
use Workflow\Activity;
class SendEmailVerificationEmailActivity extends Activity
{
public function execute($email)
{
Mail::to($email)->send(new VerifyEmail($this->workflowId()));
}
}
Activities are what actually moves a workflow along. A workflow with a single activity is done after that activity is done. If that activity fails, the workflow will retry it until it succeeds.
Activities are what actually change things. They send emails, rename files, update databases. They have side effects.
Workflows
<?php
namespace App\Workflows\VerifyEmail;
use Workflow\ActivityStub;
use Workflow\SignalMethod;
use Workflow\Workflow;
use Workflow\WorkflowStub;
class VerifyEmailWorkflow extends Workflow
{
private bool $verified = false;
#[SignalMethod]
public function verify()
{
$this->verified = true;
}
public function execute($email = '', $password = '')
{
yield ActivityStub::make(SendEmailVerificationEmailActivity::class, $email);
yield WorkflowStub::await(fn () => $this->verified);
yield ActivityStub::make(VerifyEmailActivity::class, $email, $password);
}
}
Workflows are what capture the logic of the business process. They orchestrate activities. They start them, wait for them to finish, collect the results and based on the results conditionally call other activities. But they don't change anything. They don't have side effects other than running activities. They can be suspended and resumed. This is why it doesn't matter if you wait for a month inside of a workflow. It will just suspend itself for a month, not taking up any worker resources.
Workflows capture the developer experience of orchestration but it is still based on choreography and all of the benefits that come with it!
Developer Experience
If you've made it this far, you might thinking, "How exactly is orchestration a better developer experience?" Okay, that's fair. Think about how you would have written the email verification above instead of using a workflow. Pretty simple right?
Now imagine you receive updated requirements as follows, "We want a second email after the link in the first one expires. And if the second email expires, we want a third email apologizing and telling us that we need to register again."
Here's a diff of all it would take to change the above workflow to meet those requirements.
Not only is it an easier change but all your logic is in a single place that makes it easier to reason about rather than scattered throughout the codebase in a bunch of different handlers.
Digging Deeper
Now if you're still skeptical you might be thinking, "Can't I already do this with Laravel queues?" And well, yes, in a very obvious sense because Laravel Workflow is written only using queues! You would just need to write similar code to what's in Laravel Workflow. But what is in it?
It is the same type of code that is in AWS Step Functions, Azure Durable Functions and Temporal.
These are all built on coroutines.
In an ideal world, our workflows would never crash. In a slightly less than ideal world, if our workflow crashes, we could load it up, restore the exact execution state and retry it from where it crashed. We don't live in either of those worlds.
Take notice of the yield
keywords from the workflow above. Because PHP (and most other languages) cannot save their execution state, coroutines rather than normal functions are used inside of workflows (but not activities). A coroutine will be called multiple times in order to execute to completion.
Even though the above workflow will execute to completion effectively once, it will still be partially executed four different times. The results of activities are cached so that only failed activities will be called again. Successful activities get skipped.
Let's walk through it.
Step By Step
The first time the workflow executes, it will reach the call to SendVerificationEmailActivity
, start that activity, and then exit. Remember, workflows suspend execution while an activity is running. After the SendVerificationEmailActivity
finishes, it will resume execution of the workflow. This brings us to…
The second time the workflow is executed, it will reach the call to SendVerificationEmailActivity
and skip it because it will already have the result of that activity. Then it will reach the call to WorkflowStub::await()
which allows the workflow to suspend itself and wait for an external signal. In this case, it will come from the user clicking on the verification link they receive in their email. Once the workflow is signaled then it will execute for…
The third time, both the calls to SendVerificationEmailActivity
and WorkflowStub::await()
are skipped. This means that the VerifyEmailActivity
will be started. After the final activity has executed we still have…
The final time the workflow is called, there is nothing left to do so the workflow completes. This final call allows the workflow to optionally combine the results from the previous activities.
If we take a look at the output of php artisan queue:work
we can better see how the workflow and individual activities are interleaved.
Heartbeats
Another great use case for workflows is any long running process such as converting video.
<?php
namespace App\Workflows\ConvertVideo;
use FFMpeg\FFMpeg;
use FFMpeg\Format\Video\WebM;
use Workflow\Activity;
class ConvertVideoWebmActivity extends Activity
{
public $timeout = 5;
public function execute($input, $output)
{
$ffmpeg = FFMpeg::create();
$video = $ffmpeg->open($input);
$format = new WebM();
$format->on('progress', fn () => $this->heartbeat());
$video->save($format, $output);
}
}
Because activities support heartbeats unlike normal Laravel jobs, we can set a reasonable timeout of 5 seconds but let ffmpeg run for as long as it needs.
vs.
If you're still interested in learning more about workflows outside of the Laravel package that I wrote, I highly recommend this video from Temporal.
Thanks for reading!
Top comments (0)