Skip to main content

· 5 min read
Richard

Suppose we are working on a Laravel application that offers trip booking. A typical trip booking involves several steps such as:

  1. Booking a flight.
  2. Booking a hotel.
  3. Booking a rental car.

Our customers expect an all-or-nothing transaction — it doesn’t make sense to book a hotel without a flight. Now imagine each of these booking steps being represented by a distinct API.

Together, these steps form a distributed transaction spanning multiple services and databases. For a successful booking, all three APIs must accomplish their individual local transactions. If any step fails, the preceding successful transactions need to be reversed in an orderly fashion. With money and bookings at stake, we can’t merely erase prior transactions — we need an immutable record of attempts and failures. Thus, we should compile a list of compensatory actions for execution in the event of a failure.

Prerequisites

To follow this tutorial, you should:

  1. Set up a local development environment for Laravel Workflow applications in PHP or use the sample app in a GitHub codespace.
  2. Familiarize yourself with the basics of starting a Laravel Workflow project by reviewing the documentation.
  3. Review the Saga architecture pattern.

Sagas are an established design pattern for managing complex, long-running operations:

  1. A Saga manages transactions using a sequence of local transactions.
  2. A local transaction is a work unit performed by a saga participant (a microservice).
  3. Each operation in the Saga can be reversed by a compensatory transaction.
  4. The Saga pattern assures that all operations are either completed successfully or the corresponding compensation transactions are run to reverse any completed work.

Laravel Workflow provides inherent support for the Saga pattern, simplifying the process of handling rollbacks and executing compensatory transactions.

Booking Saga Flow

We will visualize the Saga pattern for our trip booking scenario with a diagram.

trip booking saga

Workflow Implementation

We’ll begin by creating a high-level flow of our trip booking process, which we’ll name BookingSagaWorkflow.

class BookingSagaWorkflow extends Workflow  
{
public function execute()
{
}
}

Next, we’ll imbue our saga with logic, by adding booking steps:

class BookingSagaWorkflow extends Workflow  
{
public function execute()
{
try {
$flightId = yield ActivityStub::make(BookFlightActivity::class);
$hotelId = yield ActivityStub::make(BookHotelActivity::class);
$carId = yield ActivityStub::make(BookRentalCarActivity::class);
} catch (Throwable $th) {
}
}
}

Everything inside the try block is our "happy path". If any steps within this distributed transaction fail, we move into the catch block and execute compensations.

Adding Compensations

class BookingSagaWorkflow extends Workflow  
{
public function execute()
{
try {
$flightId = yield ActivityStub::make(BookFlightActivity::class);
$this->addCompensation(fn () => ActivityStub::make(CancelFlightActivity::class, $flightId));

$hotelId = yield ActivityStub::make(BookHotelActivity::class);
$this->addCompensation(fn () => ActivityStub::make(CancelHotelActivity::class, $hotelId));

$carId = yield ActivityStub::make(BookRentalCarActivity::class);
$this->addCompensation(fn () => ActivityStub::make(CancelRentalCarActivity::class, $carId));
} catch (Throwable $th) {
}
}
}

In the above code, we sequentially book a flight, a hotel, and a car. We use the $this->addCompensation() method to add a compensation, providing a callable to reverse a distributed transaction.

Executing the Compensation Strategy

With the above setup, we can finalize our saga and populate the catch block:

class BookingSagaWorkflow extends Workflow  
{
public function execute()
{
try {
$flightId = yield ActivityStub::make(BookFlightActivity::class);
$this->addCompensation(fn () => ActivityStub::make(CancelFlightActivity::class, $flightId));

$hotelId = yield ActivityStub::make(BookHotelActivity::class);
$this->addCompensation(fn () => ActivityStub::make(CancelHotelActivity::class, $hotelId));

$carId = yield ActivityStub::make(BookRentalCarActivity::class);
$this->addCompensation(fn () => ActivityStub::make(CancelRentalCarActivity::class, $carId));
} catch (Throwable $th) {
yield from $this->compensate();
throw $th;
}
}
}

Within the catch block, we call the compensate() method, which triggers the compensation strategy and executes all previously registered compensation callbacks. Once done, we rethrow the exception for debugging.

By default, compensations execute sequentially. To run them in parallel, use $this->setParallelCompensation(true). To ignore exceptions that occur inside compensation activities while keeping them sequential, use $this->setContinueWithError(true) instead.

Testing the Workflow

Let’s run this workflow with simulated failures in each activity to fully understand the process.

First, we run the workflow normally to see the sequence of bookings: flight, then hotel, then rental car.

booking saga with no errors

Next, we simulate an error with the flight booking activity. Since no bookings were made, the workflow logs the exception and fails.

booking saga error with flight

Then, we simulate an error with the hotel booking activity. The flight is booked successfully, but when the hotel booking fails, the workflow cancels the flight.

booking saga error with hotel

Finally, we simulate an error with the rental car booking. The flight and hotel are booked successfully, but when the rental car booking fails, the workflow cancels the hotel first and then the flight.

booking saga error with rental car

Conclusion

In this tutorial, we implemented the Saga architecture pattern for distributed transactions in a microservices-based application using Laravel Workflow. Writing Sagas can be complex, but Laravel Workflow takes care of the difficult parts such as handling errors and retries, and invoking compensatory transactions, allowing us to focus on the details of our application.

· 4 min read
Richard

When it comes to building web applications, managing complex processes and activities can be a daunting task. Laravel Workflow simplifies this process by providing tools for defining and managing workflows and activities. In addition, integrating a state machine library can offer more explicit control over the transitions between states or activities, resulting in a more structured and visual representation of the workflow. In this blog post, we will explore the benefits of using Laravel Workflow along with a state machine and walk through an example of integrating Laravel Workflow with Finite, a simple state machine library.

Benefits of Combining Laravel Workflow and State Machines

Using Laravel Workflow and a state machine together provides several advantages:

  1. Flexibility and modularity: Laravel Workflow allows developers to break down complex processes into smaller, modular units that are easy to maintain and update.
  2. Explicit control over transitions: State machines provide a clear visualization of workflow states, activities, and transitions, making it easier to understand and maintain.
  3. Robust error handling and retries: Laravel Workflow offers built-in support for handling errors and retries, ensuring that workflows are executed reliably and consistently.
  4. Scalability: Laravel Workflow supports queuing and parallel execution, allowing workflows to be executed asynchronously on worker servers.
  5. Integration with Laravel’s queue and event systems: This allows for seamless integration with other Laravel features and packages.

Installation Guide

To get started with Laravel Workflow and Finite, you will need to install them in your Laravel project:

For Laravel Workflow, run the following command:

composer require laravel-workflow/laravel-workflow

For Finite, run the following command:

composer require yohang/finite

Loan Application Workflow Example

The following code demonstrates how to create a LoanApplicationWorkflow using Laravel Workflow and Finite:

use Finite\StatefulInterface;  
use Finite\StateMachine\StateMachine;
use Finite\State\State;
use Finite\State\StateInterface;
use Workflow\Models\StoredWorkflow;
use Workflow\SignalMethod;
use Workflow\WorkflowStub;
use Workflow\Workflow;

class LoanApplicationWorkflow extends Workflow implements StatefulInterface
{
private $state;
private $stateMachine;

public function setFiniteState($state)
{
$this->state = $state;
}

public function getFiniteState()
{
return $this->state;
}

#[SignalMethod]
public function submit()
{
$this->stateMachine->apply('submit');
}

#[SignalMethod]
public function approve()
{
$this->stateMachine->apply('approve');
}

#[SignalMethod]
public function deny()
{
$this->stateMachine->apply('deny');
}

public function isSubmitted()
{
return $this->stateMachine->getCurrentState()->getName() === 'submitted';
}

public function isApproved()
{
return $this->stateMachine->getCurrentState()->getName() === 'approved';
}

public function isDenied()
{
return $this->stateMachine->getCurrentState()->getName() === 'denied';
}

public function __construct(
public StoredWorkflow $storedWorkflow,
...$arguments
) {
parent::__construct($storedWorkflow, $arguments);

$this->stateMachine = new StateMachine();

$this->stateMachine->addState(new State('created', StateInterface::TYPE\_INITIAL));
$this->stateMachine->addState('submitted');
$this->stateMachine->addState(new State('approved', StateInterface::TYPE\_FINAL));
$this->stateMachine->addState(new State('denied', StateInterface::TYPE\_FINAL));

$this->stateMachine->addTransition('submit', 'created', 'submitted');
$this->stateMachine->addTransition('approve', 'submitted', 'approved');
$this->stateMachine->addTransition('deny', 'submitted', 'denied');

$this->stateMachine->setObject($this);
$this->stateMachine->initialize();
}

public function execute()
{
// loan created

yield WorkflowStub::await(fn () => $this->isSubmitted());

// loan submitted

yield WorkflowStub::await(fn () => $this->isApproved() || $this->isDenied());

// loan approved/denied

return $this->stateMachine->getCurrentState()->getName();
}
}

In this example, we define a LoanApplicationWorkflow class that extends Workflow and implements StatefulInterface. The workflow has four states: created, submitted, approved or denied. The workflow transitions between these states by externally calling the submit(), approve(), and deny() signal methods.

To use the LoanApplicationWorkflow, you can create a new instance of it, start the workflow, submit the loan application, approve it, and get the output as follows:

// create workflow  
$workflow = WorkflowStub::make(LoanApplicationWorkflow::class);

// start workflow
$workflow->start();

sleep(1);

// submit signal
$workflow->submit();

sleep(1);

// approve signal
$workflow->approve();

sleep(1);

$workflow->output();
// "approved"

This is the view from Waterline.

timeline

Conclusion

Although Laravel Workflow offers a way to define and manage workflows and activities, some developers might still prefer to use a state machine to have more explicit control over the transitions between states or activities.

A state machine can provide a more structured and visual representation of the workflow, making it easier to understand and maintain. In such cases, a state machine library can be integrated with Laravel Workflow. This allows developers to define their workflow states, activities, and transitions using the state machine library while still leveraging Laravel Workflow’s features, such as queuing, parallel execution, error handling, retries, and integration with Laravel’s queue and event systems.

The Laravel developer community has created several state machine packages that can be integrated with Laravel Workflow, such as the following:

By integrating a state machine library with Laravel Workflow, developers can get the best of both worlds: the flexibility and modularity of Laravel Workflow and the explicit control and visualization of a state machine. This can help to create more maintainable, robust, and scalable workflows for complex business processes.

· 3 min read
Richard

Laravel Workflow has introduced an exciting new feature called “Child Workflows.” This addition aims to enhance the organization and maintainability of complex processes by allowing developers to encapsulate sub-processes within a parent workflow. This article will discuss the benefits of using child workflows, their similarities with running a workflow as an activity, and their compatibility with retry and resume features.

What are Child Workflows?

In Laravel Workflow, child workflows are a way to manage complex processes by breaking them down into smaller, more manageable units. They enable developers to create hierarchical and modular structures for their workflows, making them more organized and easier to maintain. A child workflow is essentially a separate workflow that is invoked within a parent workflow using the ChildWorkflowStub::make() method.

Benefits of Using Child Workflows

  1. Modularity: Child workflows promote modularity by allowing developers to encapsulate specific functionality within separate, reusable units. This enables better code organization and easier management of complex processes.
  2. Reusability: Child workflows can be invoked within multiple parent workflows, which encourages reusability and reduces code duplication.
  3. Maintainability: By breaking down complex processes into smaller units, developers can better understand, debug, and maintain their workflows.

Workflows as Activities

Child workflows are similar to running a workflow as an activity in that they both encapsulate specific functionality within a parent workflow. However, child workflows offer more flexibility and reusability than activities.

chart

Activities are single-purpose units that perform a specific action within a workflow, such as sending an email or updating a database record. On the other hand, child workflows are complete workflows in themselves, which can be composed of multiple activities and even other child workflows. This allows developers to create complex, nested structures to manage intricate processes more efficiently.

Retries and Resumes in Child Workflows

Child workflows inherit the same retry and resume features as their parent workflows, enabling developers to manage error handling and recovery more effectively. When a child workflow fails, Laravel Workflow will automatically attempt to retry the failed operation, following the configured retry policy. If the child workflow still fails after all retries have been exhausted, the parent workflow can also be configured to handle the failure accordingly.

In addition, child workflows can be resumed if they are interrupted due to a system failure or crash. This ensures that the entire process can continue from the point of interruption without losing progress or requiring manual intervention.

Conclusion

Laravel Workflow’s Child Workflows feature offers developers an effective way to manage complex processes by breaking them down into smaller, more manageable units. This enhances organization, maintainability, and reusability, making it easier for developers to build and maintain intricate workflows. With the added benefits of retry and resume features, child workflows provide a robust and efficient solution for managing complex processes in Laravel applications.

· 3 min read
Richard

effects

Workflows provide a more organized and structured approach to managing distributed processes, making it easier for developers to understand and work with complex logic.

Laravel Workflow is a powerful package for the Laravel web framework that provides tools for defining and managing workflows.

One of the key features of any workflow engine is the ability to track the history of a workflow as it is executed which allows a workflow to be retried if it fails or encounters an error. However, this also means that your workflow code must be deterministic and any non-deterministic code has to be carefully managed.

Recently, Laravel Workflow added support for side effects, which are closures containing non-deterministic code that is only executed once and the result saved. Side effects are a useful way to introduce non-deterministic behavior into a workflow, such as generating a random number or UUID.

Here is an example workflow that demonstrates side effects.

class SideEffectWorkflow extends Workflow  
{
public function execute()
{
$sideEffect = yield WorkflowStub::sideEffect(
fn () => random\_int(PHP\_INT\_MIN, PHP\_INT\_MAX)
);

$badSideEffect = random\_int(PHP\_INT\_MIN, PHP\_INT\_MAX);

$result1 = yield ActivityStub::make(SimpleActivity::class, $sideEffect);

$result2 = yield ActivityStub::make(SimpleActivity::class, $badSideEffect);

if ($sideEffect !== $result1) {
throw new Exception(
'These side effects should match because it was properly wrapped in WorkflowStub::sideEffect().'
);
}

if ($badSideEffect === $result2) {
throw new Exception(
'These side effects should not match because it was not wrapped in WorkflowStub::sideEffect().'
);
}
}
}

The activity doesn’t actually do anything. It just takes the input and passes it back out unmodified, so that we can compare the result to what we generated inside of the workflow.

class SimpleActivity extends Activity  
{
public function execute($input)
{
return $input;
}
}

In this example, the workflow generates two random integers: one using a side effect and the other using a local variable. The values of these integers are then passed to two different activities.

The first activity receives the value of the side effect, which has been saved. As a result, the value of the side effect should remain constant throughout the execution of the workflow.

The second activity receives the value of the local variable, which is not saved and will be regenerated. This means that the value of the local variable will change between executions of the workflow.

As a result, it is not expected that the value of the local variable will match the value returned from the second activity. The odds of two random integers generated using random_int(PHP_INT_MIN, PHP_INT_MAX) being equal are extremely low, since there are a very large number of possible integers in this range.

dice

It’s important to use side effects appropriately in your workflow to ensure that your workflow is reliable and can recover from failures. Only use side effects for short pieces of code that cannot fail, and make sure to use activities to perform long-running work that may fail and need to be retried, such as API requests or external processes.

Overall, side effects are a powerful tool for introducing non-deterministic behavior into your workflows. When used correctly, they can help you to add more flexibility and complexity to your application’s logic.

Laravel Workflow is a powerful tool for managing workflows in your Laravel applications, and the addition of support for side effects makes it even more powerful!

· 3 min read
Richard

Chaining is a workflow design pattern that involves the sequential execution of a series of activities, with the output of one activity potentially serving as the input to the next activity in the chain. This pattern is often used to create a linear, step-by-step process for completing a task.

chaining

In contrast, the fan-out/fan-in pattern involves dividing a task into smaller sub-tasks and then combining the results of those sub-tasks to produce the final result. This pattern is often used to parallelize a task and improve its performance by leveraging the power of multiple queue workers.

fan-out/fan-in

There are two phases: fan-out and fan-in.

In the fan-out phase, the workflow divides the main task into smaller sub-tasks and assigns each of those sub-tasks to a different activity. In the fan-in phase, the workflow collects the results of the activities and combines them to produce the final result.

The below workflow represents a simple example of a fan-out/fan-in pattern in which multiple activities are executed in parallel and their results are then merged together.

The workflow divides the task of creating a PDF into activities, with each activity responsible for rendering a single page of the document. Once the individual pages have been rendered, the fan-in phase of the workflow combines the rendered pages into a single PDF document.

namespace App\Workflows\BuildPDF;

use Workflow\ActivityStub;
use Workflow\Workflow;

class BuildPDFWorkflow extends Workflow
{
public function execute()
{
$page1 = ActivityStub::make(ConvertURLActivity::class, 'https://example.com/');
$page2 = ActivityStub::make(ConvertURLActivity::class, 'https://example.com/');

$pages = yield ActivityStub::all([$page1, $page2]);

$result = yield ActivityStub::make(MergePDFActivity::class, $pages);

return $result;
}
}

The ConvertURLActivity is passed a URL as an argument, and it converts the contents of that URL into a PDF document. Because two separate activities are created, this results in the execution of two instances of ConvertURLActivity in parallel.

namespace App\Workflows\BuildPDF;

use Illuminate\Support\Facades\Http;
use Workflow\Activity;

class ConvertURLActivity extends Activity
{
public function execute($url)
{
$fileName = uniqid() . '.pdf';

Http::withHeaders([
'Apikey' => 'YOUR-API-KEY-GOES-HERE',
])
->withOptions([
'sink' => storage_path($fileName),
])
->post('https://api.cloudmersive.com/convert/web/url/to/pdf', [
'Url' => $url,
]);

return $fileName;
}
}

Next, the BuildPDFWorkflow uses ActivityStub::all to wait for both ConvertURLActivity instances to complete. This is an example of the fan-in part of the fan-out/fan-in pattern, as it collects the results of the parallel activities and combines them into a single array of PDF files.

Finally, the BuildPDFWorkflow executes theMergePDFActivity, which is passed the array of PDFs that were generated by the ConvertURLActivity instances, and merges them into a single PDF document.

namespace App\Workflows\BuildPDF;

use setasign\Fpdi\Fpdi;
use Workflow\Activity;

class MergePDFActivity extends Activity
{
public function execute($pages)
{
$fileName = uniqid() . '.pdf';

$pdf = new Fpdi();

foreach ($pages as $page) {
$pdf->AddPage();
$pdf->setSourceFile(storage_path($page));
$pdf->useTemplate($pdf->importPage(1));
}

$pdf->Output('F', storage_path($fileName));

foreach ($pages as $page) {
unlink(storage_path($page));
}

return $fileName;
}
}

This is what the final PDF looks like…

merged PDF

Overall, using the fan-out/fan-in pattern in this way can significantly reduce the time it takes to create a PDF document, making the process more efficient and scalable.

Thanks for reading!

· 2 min read
Richard

One of the pros to using workflows is that it makes monitoring easy. Using Waterline makes it even easier!

dashboard

Look familiar? Yes, this is shamelessly based on Horizon! However, the similarity is only superficial. Waterline is geared towards workflows, not queues. In fact, Horizon is still the best way to monitor your queues and plays along nicely with it.

Waterline is to workflows what Horizon is to queues.

workflow view

At this point you can see a lot of differences! You can see the arguments passed to the workflow and the output from the completed workflow. You can see a timeline that shows each activity at a glance along with any exceptions that were thrown. There is also a list view for the activities and their results.

At the bottom are any exceptions thrown, including a stack trace and a snippet of code showing the exact line. This makes debugging a breeze.

If you’re familiar with Horizon then installing Waterline will be like déjà vu but the setup is simpler because Waterline doesn’t care about queues, only workflows.

Installation

You can find the official documentation here but setup is simple.

composer require laravel-workflow/waterline  

php artisan waterline:publish

That’s it! Now you should be able to view the /waterline URL in your app. By default this URL is only available in local environments. To view this outside of local environments you will have to modify the WaterlineServiceProvider.

Gate::define('viewWaterline', function ($user) {  
return in_array($user->email, [
'admin@example.com',
]);
});

This will allow only the single admin user to access the Waterline UI.

If you want more context for the workflow that is show in the screenshot above, make sure to read my previous article.

Thanks for reading!

· 3 min read
Richard

Many services like Cloud Image offer a way to invalidate cached images so that they are pulled from your server again. This is useful if you have updated the source image on your server and want future requests to use the latest copy.

However, it can be challenging if you want to automate this and also ensure that the image has been invalidated. This is because most invalidation APIs are asynchronous. When you request an image to be cleared from the cache, the API will return a response immediately. Then the actual process to clear the image from the cache runs in the background, sometimes taking up to 30 seconds before the image is updated. You could simply trust that the process works but it is also possible to be 100% sure with an automated workflow.

The workflow we need to write is as follows:

  1. Check the currently cached image’s timestamp via HEAD call
  2. Invalidate cached image via API call
  3. Check if the image timestamp has changed
  4. If not, wait a while and check again
  5. After 3 failed checks, go back to step 2

The workflow consists of two activities. The first activity gets the current timestamp of the image. This timestamp is used to determine if the image was actually cleared from the cache or not.

namespace App\Workflows\InvalidateCache;

use Illuminate\Support\Facades\Http;
use Workflow\Activity;

class CheckImageDateActivity extends Activity
{
public function execute($url)
{
return Http::head('https://' . config('services.cloudimage.token') . '.cloudimg.io/' . $url)
->header('date');
}
}

The second activity makes the actual call to Cloud Image’s API to invalidate the image from the cache.

namespace App\Workflows\InvalidateCache;

use Illuminate\Support\Facades\Http;
use Workflow\Activity;

class InvalidateCacheActivity extends Activity
{
public function execute($url)
{
Http::withHeaders([
'X-Client-key' => config('services.cloudimage.key'),
'Content-Type' => 'application/json'
])->post('https://api.cloudimage.com/invalidate', [
'scope' => 'original',
'urls' => [
'/' . $url
],
]);
}
}

The workflow looks as follows and is the same process as outlined before.

namespace App\Workflows\InvalidateCache;

use Workflow\ActivityStub;
use Workflow\Workflow;
use Workflow\WorkflowStub;

class InvalidateCacheWorkflow extends Workflow
{
public function execute($url)
{
$oldDate = yield ActivityStub::make(CheckImageDateActivity::class, $url);

while (true) {
yield ActivityStub::make(InvalidateCacheActivity::class, $url);

for ($i = 0; $i < 3; ++$i) {
yield WorkflowStub::timer(30);

$newDate = yield ActivityStub::make(CheckImageDateActivity::class, $url);

if ($oldDate !== $newDate) return;
}
}
}
}

Line 13 uses an activity to get the current timestamp of the image we want to invalidate from the cache.

Line 15 starts a loop that only exits when the image timestamp has changed.

Line 16 uses an activity to invalidate the image from the cache.

Line 18 starts a loop that tries a maximum of three times to first sleep and then check if the image timestamp has change, after three times the loop restarts at line 15.

Line 19 sleeps the workflow for 30 seconds. This gives Cloud Image time to clear the image from their cache before checking the timestamp again.

Lines 21–23 reuse the activity from earlier to get the current timestamp of the cached image and compare it to the one saved on line 13. If the timestamps don’t match then the image has successfully been cleared from the cache and we can exit the workflow. Otherwise, after three attempts, we start the process over again.

This is how the workflow execution looks in the queue assuming no retries are needed.

workflow execution

The added benefit is that your image is now cached again and will be fast for the next user! Thanks for reading!

· 2 min read
Richard

FFmpeg is a free, open-source software project allowing you to record, convert and stream audio and video.

Laravel Queues are great for long running tasks. Converting video takes a long time! With Laravel Workflow, you can harness the power of queues to convert videos in the background and easily manage the process.

Requirements

  1. You’ll need to install FFmpeg
  2. Then composer require php-ffmpeg/php-ffmpeg (docs)
  3. Finally composer require laravel-workflow/laravel-workflow (docs)

Workflow

A workflow is an easy way to orchestrate activities. A workflow that converts a video from one format to another might have several activities, such as downloading the video from storage, the actual conversion, and then finally notifying the user that it’s finished.

For simplicity, the workflow we are making today will only contain the most interesting activity, converting the video.

namespace App\Workflows\ConvertVideo;

use Workflow\ActivityStub;
use Workflow\Workflow;

class ConvertVideoWorkflow extends Workflow
{
public function execute()
{
yield ActivityStub::make(
ConvertVideoWebmActivity::class,
storage_path('app/oceans.mp4'),
storage_path('app/oceans.webm'),
);
}
}

We need a video to convert. We can use this one:

http://vjs.zencdn.net/v/oceans.mp4

Download it and save it to your app storage folder.

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

The activity converts any input video into a WebM output video. While ffmpeg is converting the video, a progress callback is triggered which in turn heartbeats the activity.

This is necessary because we have set a reasonable timeout of 5 seconds but we also have no idea how long it will take to convert the video. As long as we send a heartbeat at least once every 5 seconds, the activity will not timeout.

heartbeat

no heartbeat

Without a heartbeat, the worker will be killed after the timeout of 5 seconds is reached.

To actually run the workflow you just need to call:

WorkflowStub::make(ConvertVideoWorkflow::class)->start();

And that’s it!

· 5 min read
Richard

A typical registration process goes as follows:

  1. User fills out registration form and submits it
  2. Laravel creates user in database with null email_verified_at
  3. Laravel sends email with a code, or a link back to our website
  4. User enters code, or clicks link
  5. Laravel sets email_verified_at to the current time

What’s wrong with this? Nothing. But like all things, as soon as real world complexity creeps in, this pattern could become painful. What if you wanted to send an email after the code or link expires? And do you really need a user in your database if they never verify their email address?

Let’s take this trivial example and replace it with a workflow. This is based on the Laravel Workflow library.

Get Started

Create a standard Laravel application and create the following files. First, the API routes.

use App\Workflows\VerifyEmail\VerifyEmailWorkflow;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Route;
use Workflow\WorkflowStub;

Route::get('/register', function () {
$workflow = WorkflowStub::make(VerifyEmailWorkflow::class);

$workflow->start(
'test+1@example.com',
Hash::make('password'),
);

return response()->json([
'workflow_id' => $workflow->id(),
]);
});

Route::get('/verify-email', function () {
$workflow = WorkflowStub::load(request('workflow_id'));

$workflow->verify();

return response()->json('ok');
})->name('verify-email');

The register route creates a new VerifyEmailWorkflow , passes in the email and password, and then starts the workflow. Notice that we hash the password before giving it to the workflow. This prevents the plain text from being stored in the workflow logs.

The verify-email route receives a workflow id, loads it and then calls the verify() signal method.

Now let’s take a look at the actual workflow.

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

Take notice of the yield keywords. 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.

graph

Even though this 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.

But notice that any code we write between these calls will be called multiple times. That’s why your code needs to be deterministic inside of workflow methods! If your code has four executions, each at different times, they must still all behave the same. There are no such limitations within activity methods.

Step By Step

The first time the workflow executes, it will reach the call to SendEmailVerificationEmailActivity , start that activity, and then exit. Workflows suspend execution while an activity is running. After the SendEmailVerificationEmailActivity 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 SendEmailVerificationEmailActivity 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 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 SendEmailVerificationEmailActivity 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.

Now let’s take a look at the activities.

The first activity just sends the user an email.

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

The email contains a temporary signed URL that includes the workflow ID.

namespace App\Mail;

use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\URL;

class VerifyEmail extends Mailable
{
use Queueable, SerializesModels;

private $workflowId;

public function __construct($workflowId)
{
$this->workflowId = $workflowId;
}

public function envelope()
{
return new Envelope(
subject: 'Verify Email',
);
}

public function content()
{
return new Content(
view: 'emails.verify-email',
with: [
'url' => URL::temporarySignedRoute(
'verify-email',
now()->addMinutes(30),
['workflow_id' => $this->workflowId],
),
],
);
}

public function attachments()
{
return [];
}
}

The user gets the URL in a clickable link.

<a href="{{ $url }}">verification link</a>

This link takes the user to the verify-email route from our API routes, which will then start the final activity.

namespace App\Workflows\VerifyEmail;

use App\Models\User;
use Workflow\Activity;

class VerifyEmailActivity extends Activity
{
public function execute($email, $password)
{
$user = new User();
$user->name = '';
$user->email = $email;
$user->email_verified_at = now();
$user->password = $password;
$user->save();
}
}

We have created the user and verified their email address at the same time. Neat!

Wrapping Up

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.

queue worker

We can see the four different executions of the workflow, the individual activities and the signal we sent.

The Laravel Workflow library is heavily inspired by Temporal but powered by Laravel Queues.

Thanks for reading!