gdbots/pbjx
PBJx is a PHP library for building message-driven GDBots applications. It extends PBJ with schema-based messages, validation, serialization, and command/query/event handling to support reliable service communication and workflow processing.
gdbots/pbjx is not a Laravel package — it is the service-bus/runtime layer that sits on top of gdbots/pbj messages. If you haven't used gdbots/pbj yet, start there: pbjx commands, events, and requests are all pbj messages, defined by a schema and generated into PHP classes. You cannot meaningfully use pbjx without that mental model.
Minimal install:
composer require gdbots/pbjx
You will almost certainly also want gdbots/pbj (usually pulled transitively) and a concrete event store/transport if you're not using the in-memory ones. Check composer.json for the exact pbj version constraint — mismatched pbj/pbjx majors are the most common first failure.
Where to look first, in order:
README.md — usually the only prose; it maps the bus interfaces.src/Pbjx.php — the container/registry that wires buses, services, stores, and transports together. Read the constructor.src/CommandBus.php, src/EventBus.php, src/RequestBus.php — these are the three interfaces you'll inject in application code.src/Service.php / src/AbstractService.php — the contract your handlers implement (handleCommand, handleEvent, handleRequest).tests/ — because this package has ~0 stars and 0 dependents, the test suite is the real documentation. Look for the in-memory store/transport and copy those setups.First use case: dispatch a command from a controller and handle it.
// app/Messaging/CreateWidgetCommand.php is generated from a pbj schema, e.g.
// pbj:schema:message name="acme:widget:command:create-widget" ...
// app/Messaging/CreateWidgetService.php
namespace App\Messaging;
use Gdbots\Pbjx\AbstractService;
use Gdbots\Pbj\Message;
final class CreateWidgetService extends AbstractService
{
public function handleCommand(Message $command, ?Message $context = null): void
{
// validate, persist, then publish the resulting event
$this->getEventBus()->publish($event, $context);
}
}
Wire it up in a service provider (no Laravel-specific glue ships with the package):
// app/Providers/MessagingServiceProvider.php
public function register(): void
{
$this->app->singleton(\Gdbots\Pbjx\Pbjx::class, function ($app) {
$pbjx = new \Gdbots\Pbjx\Pbjx(/* config array per README */);
$pbjx->getCommandBus()->subscribe('acme:widget:command:create-widget', new CreateWidgetService(...));
return $pbjx;
});
}
Then in a controller:
public function store(CreateWidgetCommand $command, CommandBus $bus)
{
$bus->dispatch($command);
return response()->noContent(202);
}
Do not expect a facade, an artisan command, or a queue driver. You are assembling a bus, and the assembly is on you.
Inject the specific bus, not the container. CommandBus, EventBus, and RequestBus are separate interfaces. Request handlers shouldn't be able to publish events; command handlers shouldn't dispatch other commands synchronously in a loop. Type-hinting the narrow interface is the cheapest enforcement you get.
One service class per message type. The pbjx model is Service objects registered against a message schema CURIE (e.g. acme:widget:command:create-widget). Keep them small and side-effect-light; the bus will fan out the same event to many subscribers.
Enrichers are your cross-cutting layer — the closest thing to middleware. Use them to stamp tenant ID, actor, correlation/causation IDs, and timestamps onto every message before it hits a service:
final class TenantEnricher implements \Gdbots\Pbjx\Enricher
{
public function enrich(\Gdbots\Pbj\Message $message, ?\Gdbots\Pbj\Message $context = null): void
{
$message->set('tenant_id', $this->tenantResolver->id());
}
}
Register enrichers once at bootstrap; they run per-message and are where you should put anything that would otherwise be a duplicated line in every handler.
Validators run before handlers. Put schema-level validation (required fields, enum membership) in a Validator, not in the handler. Handlers should assume valid input and focus on orchestration.
Event store + projectors is the read-side pattern. Write commands append to the EventStore; subscribers marked as projectors consume those events and update your Eloquent read models. In Laravel terms:
final class WidgetProjector implements \Gdbots\Pbjx\Subscriber
{
public function handleEvent(Message $event, ?Message $context = null): void
{
match ($event::schema()->getCurie()->toString()) {
'acme:widget:event:widget-created' => WidgetModel::create([...]),
default => null,
};
}
}
Use the transport for async, the bus for sync. In-process dispatch is synchronous. If you want a queue, publish through the transport implementation (SQS, DynamoDB streams, or your own) and run a worker that pulls and re-dispatches. The Scheduler interface (getNextCommand) is the long-poll loop primitive — wrap it in an artisan command with while (true) and a signal handler. Laravel's queue system is not plug-compatible; don't try to make a pbjx transport subclass a ShouldQueue.
Integration tip: let the pbj messages be DTOs, not Eloquent models. Build messages in form requests / actions, dispatch, and let projectors own persistence. Mixing Model::save() into a command handler gives you dual writes that the event store can't replay.
Testing pattern. Swap in the in-memory event store and in-memory transport in your TestCase (bind them in a testing service provider). Because events are pbj messages, assertions are assertEquals($expectedEvent->toArray(), $published->toArray()) against a captured bus. This is fast and avoids mocking the bus interfaces by hand.
Version drift with gdbots/pbj. pbjx's API surface tracks pbj closely. Pin both, and read the changelog of pbj when bumping pbjx. A schema regeneration is often required after a pbj minor bump, and the failure mode is a confusing "unknown field" or missing method rather than an install error.
Messages are immutable-ish and schema-validated. You generally cannot new Foo(['a' => 1]) — use the generated constructor plus setters, or ::fromArray(), and then validate(). Setting a field that isn't in the schema throws. If you're hand-writing test fixtures, generate them from a real message instance rather than by hand.
The context argument is easy to drop. Every bus/service/subscriber method threads an optional $context message. If your tenant/user/correlation data lives in context and you forget to pass it at one hop, downstream enrichers silently stamp the wrong defaults. Make a helper that always supplies it, or always pass enrichers the tenant explicitly.
Event store requires real infrastructure. The DynamoDB event store needs specific tables and indexes provisioned per the README. This is the single biggest "works on my machine" trap — local development typically needs the in-memory store plus a feature flag, or a real DynamoDB local instance. Budget time for this before writing a single handler.
Idempotency is your responsibility. At-least-once delivery is the norm for any transport. Events carry IDs — dedupe in projectors keyed on event ID, or you'll double-insert on retries. The package gives you identifiers; it does not give you deduplication.
Ordering is per-stream, not global. Don't design a projector that assumes one event type always precedes another across aggregate boundaries.
Debugging: log toArray(). pbj messages serialize cleanly to arrays (and JSON), so a single middleware or enricher that logs $message::schema()->getCurie() + $message->toArray() before dispatch will save you hours. Exception classes under Gdbots\Pbjx\Exception are more informative than they look — InvalidArgumentException from the bus usually means "no service registered for this schema CURIE", not bad input.
Empty-registration surprises. A command with no subscribed service typically throws at dispatch time, not at boot. Add a boot-time assertion that every message CURIE in your schema manifest has a handler; a small artisan command that walks the generated classes pays for itself.
Extension points to reach for instead of forking: custom Transport, custom EventStore, custom Scheduler, and the Enricher/Validator/Subscriber interfaces. The bus itself is an interface too — if you need Laravel-native events, write an adapter implementing EventBus that forwards to Event::dispatch(). Don't subclass the concrete buses; they assume the pbjx composition model.
Community risk is real. Zero stars, zero dependents, and a maintainer-published ecosystem means Stack Overflow will not help you. Budget for reading src/ directly, and consider vendoring or at least pinning an exact version in composer.lock with an internal note on the pbjx/pbj pair you validated. If your team is small, weigh whether a thinner hand-rolled CQRS layer over Laravel's queue + events would be less total cost — pbjx's value is highest when you actually need pbj's schema-driven messages and cross-language compatibility.
How can I help you explore Laravel packages today?