The Transactional Outbox pattern is a reliable way to ensure data consistency between microservices and other systems, especially in systems with distributed transactions. In Laravel, this pattern can be implemented as follows:
1. Create a table to store events (outbox_messages)
The first step is to create a migration that creates a table to store outbox messages or events:
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class () extends Migration {
public function up(): void
{
Schema::create('outbox_messages', function (Blueprint $table): void {
$table->id();
$table->string('job_class');
$table->json('payload');
$table->integer('attempts')->default(0);
$table->longText('description')->nullable();
$table->string('status')->default(\App\Enums\OutboxMessageStatusEnum::PENDING);
$table->string('queue_type')->default(\App\Enums\QueueTypeEnum::DEFAULT);
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('outbox_messages');
}
};
Thus, we will store the job class (job_class), payload (payload), number of delivery attempts, error description, status and queue type.
2. Create OutboxMessage model
For convenient work with the table, create a model that defines which fields are available for mass filling and their type conversion.
<?php
declare(strict_types=1);
namespace App\Models;
use App\Enums\OutboxMessageStatusEnum;
use App\Enums\QueueTypeEnum;
use Database\Factories\OutboxMessageFactory;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class OutboxMessage extends Model
{
/** @use HasFactory<OutboxMessageFactory> */
use HasFactory;
protected $fillable = [
'job_class',
'payload',
'attempts',
'description',
'status',
'queue_type',
];
protected function casts(): array
{
return [
'payload' => 'array',
'attempts' => 'int',
'status' => OutboxMessageStatusEnum::class,
'queue_type' => QueueTypeEnum::class,
];
}
}
3.Defining Enum for statuses and queue types
It is convenient to use Enum to control message status and queue type. An example of two enums:
<?php
declare(strict_types=1);
namespace App\Enums;
enum OutboxMessageStatusEnum: string
{
case PENDING = 'pending';
case SENT = 'sent';
case ERROR = 'error';
}
– QueueTypeEnum:
<?php
declare(strict_types=1);
namespace App\Enums;
enum QueueTypeEnum: string
{
case DEFAULT = 'default';
case BILLY = 'invoice';
}
4. Creating a DTO for Outbox messages
To conveniently transfer event data, create a Data Transfer Object (DTO):
<?php
declare(strict_types=1);
namespace App\Data\OutboxMessage;
use App\Enums\OutboxMessageStatusEnum;
use App\Enums\QueueTypeEnum;
use Illuminate\Contracts\Queue\ShouldQueue;
use Spatie\LaravelData\Data;
final class OutboxMessageData extends Data
{
/**
* @param class-string<ShouldQueue> $job_class
* @param array<string, scalar> $payload
*/
public function __construct(
public string $job_class,
public array $payload,
public int $attempts = 0,
public OutboxMessageStatusEnum $status = OutboxMessageStatusEnum::PENDING,
public QueueTypeEnum $queue_type = QueueTypeEnum::DEFAULT,
public ?int $id = null,
public ?string $description = null,
) {}
/**
* @return array<string, mixed>
*/
public function toEntityArray(): array
{
$data = $this->toArray();
unset($data['id']);
return $data;
}
}
5. Action to create an Outbox message
Let's describe the Action that will save the message to the database:
<?php
declare(strict_types=1);
namespace App\Actions\OutboxMessage;
use App\Data\OutboxMessage\OutboxMessageData;
use App\Models\OutboxMessage;
use Lorisleiva\Actions\Concerns\AsAction;
/**
* @method static OutboxMessageData run(OutboxMessageData $outboxMessageData)
*/
final class CreateOutboxMessage
{
use AsAction;
public function handle(OutboxMessageData $message): OutboxMessageData
{
$messageModel = OutboxMessage::create($message->toEntityArray());
$message->id = $messageModel->id;
return $message;
}
}
6. Creating a Job to send events
Now let's create a Laravel Job class that will perform the sending of a message. In this example, the event is related to an account update, for example:
<?php
declare(strict_types=1);
namespace App\Jobs;
use App\Actions\Invoices\UpdateInvoiceFromBilly;
use App\Actions\OutboxMessage\CreateOutboxMessage;
use App\Data\OutboxMessage\OutboxMessageData;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
final class InvoiceUpdatedQueueJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public function __construct(
public int $id
) {}
public static function dispatchOutbox(int $id): void
{
CreateOutboxMessage::run(new OutboxMessageData(
job_class: self::class,
payload: ['id' => $id],
));
}
public function handle(): void
{
app(UpdateInvoiceFromBilly::class)->handle($this->id);
}
}
Note the dispatchOutbox method - thanks to it, when InvoiceUpdatedQueueueJob::dispatchOutbox($id) is called, the message is stored in the outbox_messages table.
7. Handling Outbox Messages
To dispatch events accumulated in the table, you can create a console command. An example command that retrieves all pending messages and sends them to RabbitMQ is as follows:
<?php
declare(strict_types=1);
namespace App\Console\Commands\OutboxMessage;
use App\Actions\OutboxMessage\DispatchOutboxMessage;
use App\Actions\OutboxMessage\GetAllPendingMessages;
use App\Data\OutboxMessage\OutboxMessageData;
use Illuminate\Console\Command;
final class OutboxProcessCommand extends Command
{
protected $signature = 'outbox:process';
protected $description = 'Send Outbox messages to Rabbit MQ';
public function handle(): void
{
$messages = GetAllPendingMessages::run();
$this->withProgressBar($messages, function (OutboxMessageData $messageData): void {
DispatchOutboxMessage::run($messageData);
});
}
}
The DispatchOutboxMessage action is responsible for sending attempts, handling errors and updating the status of the message. An example of DispatchOutboxMessage implementation can be seen in the source code.
8. Additional commands
To keep the table clean, you can add a command to delete old messages:
<?php
declare(strict_types=1);
namespace App\Actions\OutboxMessage;
use App\Enums\OutboxMessageStatusEnum;
use App\Models\OutboxMessage;
use Carbon\Carbon;
use Lorisleiva\Actions\Concerns\AsAction;
/**
* @method static void run()
*/
final class DeleteOldEvents
{
use AsAction;
public const int DAYS_TO_KEEP = 60;
public function handle(): void
{
OutboxMessage::where('status', OutboxMessageStatusEnum::SENT)
->where('created_at', '<', Carbon::now()->subDays(self::DAYS_TO_KEEP))
->delete();
}
}
As well as a console command to delete old messages:
<?php
declare(strict_types=1);
namespace App\Console\Commands\OutboxMessage;
use App\Actions\OutboxMessage\DeleteOldEvents;
use Illuminate\Console\Command;
final class MessagesDeleteOldCommand extends Command
{
protected $signature = 'messages:delete-old';
protected $description = 'Delete old outbox messages';
public function handle(): void
{
DeleteOldEvents::run();
$this->info('Messages has been deleted');
}
}
If you need to retry sending a specific event, you can implement the OutboxRetryCommand:
<?php
declare(strict_types=1);
namespace App\Console\Commands\OutboxMessage;
use App\Actions\OutboxMessage\DispatchOutboxMessage;
use App\Actions\OutboxMessage\GetOutboxMessageById;
use Illuminate\Console\Command;
final class OutboxRetryCommand extends Command
{
protected $signature = 'outbox:retry {id}';
protected $description = 'Retry event by its ID';
public function handle(): void
{
$id = (int) $this->argument('id');
$message = GetOutboxMessageById::run($id);
DispatchOutboxMessage::run($message);
}
}
Conclusion
In this article, we have looked at one approach to implementing Transactional Outbox in Laravel. The Outbox Messages pattern allows you to:
- Guarantee event delivery even if the message broker (RabbitMQ) is temporarily unavailable;
- Store event history for further analysis and replay;
- Eliminate unsynchronization between the database and the queue.
Run a console command (e.g., cron or schedule) to periodically process accumulated messages. Add commands to clear history or resend events if needed.
Hopefully, these instructions will help you implement reliable and fault-tolerant communication between your applications. If you have any questions or suggestions - write in the comments or contact me directly!