Documentation generated by Claude after complete exploration of the Backslash codebase
Backslash is a modern, opinionated PHP library designed to facilitate the integration of CQRS (Command Query Responsibility Segregation) and Event Sourcing patterns in PHP applications.
- Framework-agnostic: Compatible with Laravel, Symfony, Slim, or standalone
- Production-tested: Used in production for 7+ years at the First Nations of Quebec and Labrador Health and Social Services Commission
- PHP 8.2+ with PDO support (MySQL/SQLite)
- Open-source under MIT license
- Zero external framework dependencies
Unlike traditional Event Sourcing approaches based on predefined aggregates, Backslash uses Dynamic Consistency Boundaries that allow defining at runtime which events are relevant for each decision.
Command → Dispatcher → Handler → Repository.loadModel(Query)
→ EventStore → Model.applyEvents() → Business Logic → Model.record()
→ Repository.storeChanges() → EventStore.append() → EventBus.publish()
EventBus.publish() → EventHandlers (Projectors)
→ ProjectionStore.find() → Update Projection → ProjectionStore.store()
CommandDispatcher/ - Routes commands to handlers
Dispatcher.php- Main dispatcher with middleware supportHandlerInterface.php- Contract for command handlersHandleCommandTrait.php- Auto-routes tohandle{CommandName}methods
Repository/ - Loading and storing models
Repository.php- Repository with middlewareCore.php- Core implementation (event loading, model recreation, persistence)
Model/ - Base classes for models
AbstractModel.php- Base class for domain modelsModelTrait.php- Event recording, replay, change trackingModelInterface.php- Contract for models
Event/ - Event classes and interfaces
EventInterface.php- Base contract with serializationRecordedEvent.php- Wraps events with metadata and timestampRecordedEventStream.php- Collection of eventsIdentifiers.php- Multi-entity identification for consistency boundariesMetadata.php- Metadata storage
EventNameResolver/ - Conversion between class names and short names
EventNameResolverInterface.php- Contract for name resolution
EventBus/ - Publishes events to handlers
EventBus.php- Main event bus with middlewarePublisher.php- Routes events to subscribed handlersEventHandlerInterface.php- Contract for handlersEventHandlerTrait.php- Auto-routes tohandle{EventName}methodsEventHandlerProxy.php- Lazy-loads handlers
ProjectionStore/ - Persists read models
ProjectionStoreInterface.php- Contract for projection storageProjectionStore.php- Wrapper with middleware supportInMemoryProjectionStoreAdapter.php- In-memory storage for testingPdoProjectionStoreAdapter.php- Database storage
Projection/ - Base classes for projections
ProjectionInterface.php- Contract for read modelsAbstractProjection.php- Base projection class
EventStore/ - Event storage interface
EventStoreInterface.php- Contract for persistenceEventStore.php- Adapter wrapperStoredRecordedEventStream.php- Events with sequence numbersQuery/- Query interface for filtering
PdoEventStore/ - Database-backed EventStore implementation
PdoEventStoreAdapter.php- PDO implementation with concurrency controlDriver.php- Driver abstraction (MySQL, SQLite)Config.php- Configuration for table/column namesJsonEventSerializer.php- JSON event serializationJsonIdentifiersSerializer.php- Identifier serializationJsonMetadataSerializer.php- Metadata serializationQueryToWhereClause.php- Converts queries to SQL WHERE clauses
Pdo/ - PDO utilities
PdoProxy.php- Lazy-loads PDO connections
Serializer/ - Event serialization
SerializerInterface.php- Serialization contractToArrayTrait.php- Auto-serialization using reflection
Clock/ - Time management
Clock.php- PSR Clock interface implementation
StreamEnricher/ - Event enrichment
StreamEnricherInterface.php- Adds metadata to events
PdoTransactionCommandDispatcherMiddleware/ - Wraps dispatch in DB transaction
ProjectionStoreTransactionCommandDispatcherMiddleware/ - Projection store transaction
CacheProjectionStoreMiddleware/ - Caches projection lookups
Scenario/ - BDD-style testing component
Scenario.php- Test orchestrator with in-memory event storePlay.php- Individual test scenario definitionEventPublishingMode.php- Enum for controlling event publication behaviorAssertionsTrait.php- Assertion helpersScenarioEventBusMiddleware.php- Traces published events and controls publishing based on modeScenarioProjectionStoreMiddleware.php- Traces projection updatesPublishedEvents.php- Event assertion helperUpdatedProjections.php- Projection assertion helperConstraint/- PHPUnit constraints for assertions
EventStoreReductionInspection/ - Event store analysis
StreamPublishingInspection/ - Event publishing analysis
Immutable facts about what happened.
readonly class StudentRegistered implements EventInterface
{
public function __construct(
public string $studentId,
public string $name,
) {}
public function getIdentifiers(): Identifiers
{
return new Identifiers(['studentId' => $this->studentId]);
}
// Implements toArray() and fromArray() for serialization
}Characteristics:
- Must implement
EventInterface - Include
getIdentifiers()for consistency boundaries - Serializable via
toArray()/fromArray() - Recommended to use
readonlyclasses
Decision-making components, rebuilt from events.
class Student extends AbstractModel
{
private string $studentId;
private string $name;
private bool $isRegistered = false;
// State transitions (pure, no business logic)
protected function applyStudentRegistered(StudentRegistered $event): void
{
$this->studentId = $event->studentId;
$this->name = $event->name;
$this->isRegistered = true;
}
// Business decisions (record events)
public function register(string $studentId, string $name): void
{
if ($this->isRegistered) {
throw new StudentAlreadyRegistered();
}
$this->record(new StudentRegistered($studentId, $name));
}
}Characteristics:
- Extend
AbstractModel - Rebuilt from events for each decision (not persisted)
- Two types of methods:
apply{EventName}()- pure state transitions- Public methods - business logic that records events
- Use
$this->record()to create new events
Define consistency boundaries dynamically.
use Backslash\EventStore\Query\EventClass;
use Backslash\EventStore\Query\Identifier;
// Simple query
$query = EventClass::is(StudentRegistered::class);
// Query with identifier
$query = Identifier::is('studentId', $studentId);
// Complex query
$query = EventClass::in([StudentRegistered::class, StudentUpdated::class])
->and(Identifier::is('studentId', $studentId));
// Multi-entity boundary
$query = Identifier::is('courseId', $courseId)
->or(Identifier::is('studentId', $studentId));Characteristics:
- Implement
QueryInterface - Filter by event class:
EventClass::is()orEventClass::in() - Filter by identifier:
Identifier::is('key', 'value') - Combine with
->and()and->or() - Support multi-entity boundaries
Read-optimized views.
class StudentProjection extends AbstractProjection implements JsonSerializable
{
public function __construct(
private string $id,
private string $name = '',
private array $courses = [],
) {}
public function getId(): string
{
return $this->id;
}
public function register(string $name): void
{
$this->name = $name;
}
public function enrollInCourse(string $courseId): void
{
$this->courses[] = $courseId;
}
public function jsonSerialize(): array
{
return [
'id' => $this->id,
'name' => $this->name,
'courses' => $this->courses,
];
}
}Characteristics:
- Implement
ProjectionInterfacewithgetId() - Persisted in
ProjectionStore - Updated by event handlers (projectors)
- Can implement
JsonSerializablefor API responses - Multiple projections per domain concept allowed
Intentions to change the system state.
readonly class RegisterStudentCommand
{
public function __construct(
public string $studentId,
public string $name,
) {}
}Characteristics:
- Plain PHP objects, no interface required
- Immutable data containers (use
readonly) - Imperative names:
RegisterStudentCommand - Carry all information needed for handlers
The dispatcher and event bus use a middleware chain for cross-cutting concerns.
$dispatcher->addMiddleware(new LoggingMiddleware());
$dispatcher->addMiddleware(new PdoTransactionCommandDispatcherMiddleware($pdo));
// LIFO execution (last registered wraps first)Auto-routing of commands and events to appropriate methods.
class StudentCommandHandler implements HandlerInterface
{
use HandleCommandTrait;
// Automatically routed for RegisterStudentCommand
private function handleRegisterStudent(RegisterStudentCommand $command): void
{
// ...
}
}
class StudentProjector implements EventHandlerInterface
{
use EventHandlerTrait;
// Automatically routed for StudentRegistered
private function handleStudentRegistered(
StudentRegistered $event,
RecordedEvent $recordedEvent
): void {
// ...
}
}The repository caches loaded models and tracks queries for concurrency control.
// First load
$student = $repository->loadModel(Student::class, $query);
// Second load with same query = returns cache
$sameStudent = $repository->loadModel(Student::class, $query);
// Changes are tracked
$student->register($id, $name);
// Storage with optimistic locking
$repository->storeChanges($student);Models are rebuilt by replaying all historical events.
// Load events
$stream = $eventStore->fetch($query);
// Create empty model
$student = new Student();
// Replay all events
$student->applyEvents($stream);
// Model is now in current stateQueries define what constitutes a consistency boundary.
// To register a student, we only look at their own events
$query = Identifier::is('studentId', $studentId);
$student = $repository->loadModel(Student::class, $query);
// To enroll in a course, we look at both course and student events
$query = Identifier::is('courseId', $courseId)
->or(Identifier::is('studentId', $studentId));
$enrollment = $repository->loadModel(Enrollment::class, $query);Raw events are wrapped in RecordedEvent with metadata.
// Event handler receives both
private function handleStudentRegistered(
StudentRegistered $event, // Pure domain event
RecordedEvent $recordedEvent // With metadata and timestamp
): void {
// Access metadata
$correlationId = $recordedEvent->metadata->get('correlationId');
$occurredAt = $recordedEvent->occurredAt;
// Domain data
$studentId = $event->studentId;
}// Load a model
$model = $repository->loadModel(ModelClass::class, $query);
// Store changes
$repository->storeChanges($model);
// Add middleware
$repository->addMiddleware($middleware);// Dispatch a command
$dispatcher->dispatch($command);
// Register a handler
$dispatcher->registerHandler(CommandClass::class, $handler);
// Add middleware
$dispatcher->addMiddleware($middleware);// Publish events
$eventBus->publish($stream);
// Subscribe to an event
$eventBus->subscribe(EventClass::class, $handler);
// Add middleware
$eventBus->addMiddleware($middleware);// Find a projection
$projection = $projectionStore->find($id, ProjectionClass::class);
// Store a projection
$projectionStore->store($projection);
// Check existence
$exists = $projectionStore->has($id, ProjectionClass::class);
// Remove
$projectionStore->remove($id, ProjectionClass::class);
// Transaction
$projectionStore->commit();
$projectionStore->rollback();// Fetch events
$stream = $eventStore->fetch($query, $fromSequence);
// Append events with optimistic locking
$eventStore->append($stream, $concurrencyCheck, $expectedSequence);Backslash uses optimistic locking to prevent race conditions:
- Repository tracks the query and expected sequence on load
- Before append, the query is re-executed to detect concurrent modifications
- If the stream was modified by another process,
ConcurrencyExceptionis thrown - Application can implement retry logic
try {
$student = $repository->loadModel(Student::class, $query);
$student->register($id, $name);
$repository->storeChanges($student);
} catch (ConcurrencyException $e) {
// Retry or handle conflict
}Backslash provides a BDD-style testing component with Given-When-Then syntax:
$scenario = new Scenario();
$scenario->play(
new Play()
// GIVEN - Setup initial state
->given(
new StudentRegisteredEvent('123', 'Alice')
)
// WHEN - Execute action
->when(
new EnrollInCourseCommand('123', 'MATH101')
)
// Or use a closure for direct model manipulation
->when(function (RepositoryInterface $repo): void {
$student = $repo->loadModel(Student::class, Identifier::is('studentId', '123'));
$student->enrollInCourse('MATH101');
$repo->storeChanges($student);
})
// THEN - Assert results
->then(fn(PublishedEvents $events) =>
$this->assertNotEmpty($events->getAllOf(StudentEnrolledInCourse::class))
)
->then(fn(UpdatedProjections $projections) =>
$projections->assertUpdated('123', StudentProjection::class)
)
->then(fn(RepositoryInterface $repo) =>
$this->assertCount(1, $repo->loadModel(Student::class, ...)->getCourses())
)
);Key features:
given()- accepts events or commands for setupwhen()- accepts commands or closures withRepositoryInterfacethen()- automatic parameter routing based on type hints
Event Publishing Modes:
By default, events generated in given() and when() are published to the event bus (EventPublishingMode::ALWAYS), ensuring backwards compatibility. This means projections are always updated during test setup and execution.
For more efficient testing, you can enable automatic projection detection using EventPublishingMode::DETECT:
// Apply to all tests in a test case
class StudentTest extends TestCase
{
private Scenario $scenario;
protected function setUp(): void
{
$this->scenario = new Scenario();
$this->scenario->setEventPublishingMode(EventPublishingMode::DETECT);
}
}
// Or apply to a specific test
$scenario = new Scenario();
$scenario->setEventPublishingMode(EventPublishingMode::DETECT);
$scenario->play(new Play()->given(...)->when(...)->then(...));With EventPublishingMode::DETECT, the scenario automatically analyzes your test to determine if projections are needed:
- Events in
given()are NOT published (test setup only) - Events in
when()ARE published only if assertions reference projections - More efficient as projections are only updated when actually needed for assertions
Projections are updated immediately within the same transaction as command execution, guaranteeing read-your-writes consistency.
Ability to rebuild projections from scratch by replaying historical events.
Enrich events with contextual metadata (correlation IDs, user context) as they flow through the system.
class CorrelationIdEnricher implements StreamEnricherInterface
{
public function enrich(RecordedEventStream $stream): RecordedEventStream
{
return $stream->map(function (RecordedEvent $event) {
return $event->withMetadata(
$event->metadata->with('correlationId', $this->generateId())
);
});
}
}Every change is recorded as an immutable event, providing a complete audit trail of all system modifications.
Ability to build multiple specialized read models from the same event stream.
1. User → API endpoint
2. API creates RegisterStudentCommand
3. Dispatcher.dispatch(command)
4. PdoTransactionMiddleware starts transaction
5. Handler receives command
6. Handler builds query: Identifier::is('studentId', $id)
7. Repository.loadModel(Student::class, query)
8. EventStore.fetch(query) → returns existing events (or empty)
9. Student model created and events replayed
10. Handler calls $student->register()
11. Model validates and records StudentRegistered event
12. Repository.storeChanges(student)
13. EventStore.append(changes, query, expectedSequence)
14. Optimistic locking check (ConcurrencyException if conflict)
15. Events persisted to database
16. EventBus.publish(events)
17. StudentProjector receives StudentRegistered
18. Projector loads StudentProjection from ProjectionStore
19. Projector updates projection
20. ProjectionStore.store(projection)
21. Transaction committed
22. Response sent to user
- Events: Past tense (
StudentRegistered,CourseCreated) - Commands: Imperative (
RegisterStudentCommand,CreateCourseCommand) - Models: Nouns (
Student,Course) - Projections: Noun + Projection (
StudentProjection,CourseListProjection) - Handlers: Noun + Handler type (
StudentCommandHandler,StudentProjector)
- Use
readonlyclasses for immutability - Include all relevant identifiers in
getIdentifiers() - Keep events small and focused (Single Responsibility)
- Never modify a published event (create a new event instead)
apply*methods must be pure (no side effects)- Public decision methods contain business logic
- Validate before recording events
- Don't persist directly, use the repository
- One projection = one specific read use case
- Can be denormalized to optimize queries
- Multiple projections for the same domain concept are OK
- Implement
JsonSerializableto facilitate API responses
- Define the minimal boundary necessary for the decision
- Include all entities that affect the decision
- Use
and()for restrictive filters - Use
or()for multi-entity boundaries
Implement EventInterface in the domain
Extend AbstractModel in the domain
Use EventClass and Identifier in the domain
Implement HandlerInterface with HandleCommandTrait
Implement EventHandlerInterface with EventHandlerTrait
Create Repository, Dispatcher, EventBus, ProjectionStore in application bootstrap
- Clear separation between write (business rules) and read (query optimization)
- Complete audit trail via immutable events
- Scalability: projections can be in separate databases
- Flexibility: add new projections without changing write side
- Testability: Scenario component with given-when-then syntax
- Debugging: ability to replay events to reproduce bugs
- Evolution: schema changes via new projections
- Performance: optimized queries via denormalized projections
Last updated: 2025-12-23 Version analyzed: branch 2.x-scenario