vinteenove / contracts
Conversation protocol between applications in the same process: contracts, DTOs and a capability registry
Requires
- php: ^8.0
Requires (Dev)
None
Suggests
None
Provides
None
Conflicts
None
Replaces
None
README
A conversation protocol between applications running in the same process: contracts, DTOs and a capability registry. No dependencies beyond PHP.
The problem
Applications that share a process don't talk to each other, so data gets typed twice — an accepted quote in one app becomes, by hand, a receivable in another.
The two obvious paths are both bad:
- HTTP between apps on the same server pays serialisation, authentication and latency to cross a process that is already in memory.
- One app reaching into another's classes works today and becomes a tangle in three years: any change breaks the neighbour, and nobody knows who depends on whom.
The idea in one sentence
The caller never names the callee.
use Lliure\Contracts\Registry;
$targets = Registry::providers(WritesReceivable::class);
// ['walletcon' => 'App\Walletcon\Walletcon']
The quoting app asks "who knows how to store a receivable?" and the finance app shows up because it implements the interface. Replace the finance app tomorrow and the quoting app doesn't change a line.
The four pieces
Provider
The base interface. An app implements it on its main class — in lliure v2, the
same class that already implements ServiceProviderInterface.
class Walletcon implements ServiceProviderInterface, WritesReceivable
{
public static function slug(): string { return 'walletcon'; }
public static function name(): string { return 'Walletcon'; }
// ... the capability methods
}
Capabilities
One interface per thing an app knows how to do, extending Provider.
Deliberately fine-grained: an app may know how to write a receivable and not
know how to write a payable.
A capability belongs here as soon as two apps need it — one to implement, one to
call. Finance\WritesReceivable qualified on day one: a quoting app calls it
and a finance app answers, so putting it in either would make the other depend
on it.
A capability only one app would ever touch stays in that app.
Finance
interface WritesReceivable extends Provider
{
public static function isReadyForReceivable(): bool;
public static function createReceivable(Receivable $r): ReceivableCreated;
}
isReadyForReceivable() exists because installed is not the same as usable:
a finance app with no account configured will reject the write. Asking first
lets the calling screen disable the button instead of failing mid-flow.
Receivable carries a description, an amount in cents, a due date, an
Origin, and optionally a Counterparty and a list of Installment. It
validates at the boundary — instalments that don't add up to the total are
refused, catching the classic 33.33 + 33.33 + 33.33 slip before a cent goes
adrift in the books.
There is no tenant field, on purpose. Caller and callee run in the same request under the same tenant, and the receiving app resolves it the way it does for every other write. Carrying a tenant in the DTO would let one app write into another tenant's books — a hole, not a feature.
createReceivable() must be idempotent on the origin. Calling it twice with
the same Origin returns the first result with alreadyExisted = true and
writes nothing.
Registry
Who knows how to do what, in this request.
Registry::register(Walletcon::class); // at boot, by the platform
Registry::providers(WritesReceivable::class); // slug => class
Registry::provider('walletcon', WritesReceivable::class);
Registry::hasProvider(WritesReceivable::class); // show the button?
Registry::only(WritesReceivable::class); // only one? don't ask the user
The registry is per request, not global. The platform registers at boot, and it only registers what this tenant actually has. So "registered" already means "this tenant has it" — the package never needs to know what a tenant is.
A class that doesn't implement Provider is ignored silently, as is one that
doesn't exist at all: the platform registers everything the tenant has, and most
apps don't take part in the protocol. That's the normal case, not an error.
register() takes the class name and does not look it up. The platform
registers every app the tenant has on every request; checking each one at that
point would autoload two dozen main classes for a protocol most requests never
use. providers() does the checking, so the cost lands on the request that
actually asks a question.
Declaring at boot without paying at boot
Listing a tenant's apps is not free — a query, sometimes a directory scan. Doing it at boot means every request pays, and most requests never ask the registry anything.
So the platform hands over how to find out, and the first real question runs it:
Registry::discoverWith(fn () => Capabilities::registerContractedApps());
Declaring costs nothing. The callback runs at most once per request, and only if
something calls providers(), hasProvider(), only() or provider(). Apps
registered by hand coexist with it.
Bus
The Registry answers who can do X. The Bus says X happened.
Bus::publish(new QuoteAccepted($quote->id, $total));
An app declares what it listens to, and is found the same way everything else is:
class Agenda implements ServiceProviderInterface, Subscriber
{
public static function subscribedEvents(): array
{
return [QuoteAccepted::class => 'onQuoteAccepted'];
}
public static function onQuoteAccepted(QuoteAccepted $event): void { /* ... */ }
}
Subscribing to an interface or a parent class catches every event under it, which is how an audit log hears everything about quotes with one subscription.
Which of the two you want comes down to one question: can the publisher finish its job alone?
| capability | event | |
|---|---|---|
| shape | a request: store this receivable | a statement: this quote was accepted |
| the caller | needs an answer, on screen | has already finished |
| nobody there | the feature is off | nothing happens, and that is fine |
| somebody breaks | the caller finds out | the caller never notices |
Accepting a quote and turning it into a receivable is a capability: the user confirms an amount and a due date and must see the result. Putting that same quote on a calendar is an event: the quote is accepted either way.
A listener that throws must never break the publisher. Every handler runs in
its own try/catch and the errors come back in the Delivery instead of
propagating — because if the publisher cannot continue without the other app's
work, it wanted a capability, not an event.
Delivery is synchronous and in-process: when publish() returns, every listener
has run. No queue, no retry. A listener with slow work to do should schedule it —
it is holding up the request that published.
Bus::observeWith() hands the platform every Delivery, so failures reach a log
instead of dying quietly in a return value nobody read.
Wiring it into the platform
lliure v2 — inside Bootstrap::registerServiceProvider():
public function registerServiceProvider(string $providerClass): void
{
$providerClass::boot($this);
\Lliure\Contracts\Registry::register($providerClass); // <- one line
if (is_subclass_of($providerClass, ServiceProviderInterface::class)) {
$providerClass::registerMenu($this->menuBuilder);
}
}
lliure v1 — a shim at boot, walking the tenant's contracted apps:
foreach (\App\Panel\Tools\Account::servicesContracted($instance) as $service) {
$path = $service['path'] ?? null;
if ($path) {
\Lliure\Contracts\Registry::register("\\App\\{$path}\\{$path}");
}
}
Note the package knows about neither. It takes class names and that's it.
Origin — the piece that prevents duplicates
$origin = new Origin('rentrigg', 'quote', '454');
$origin->key(); // 'rentrigg:quote:454'
The receiving app stores the key under a unique index and refuses the second write. Without it, two clicks on the button become two records and the user only finds out at month close.
The key is stable: the same quote produces the same string today and a year
from now, so the protection holds even if the user repeats the action months
later. An id containing : survives the round trip.
Counterparty — the common minimum of the other side
Deliberately poor. Each app has its own customer table with fields that only
make sense there; what travels between them is just enough for the receiver to
find who it already has or create who it lacks. Only name is required — a
quote with no identified customer exists and still has to become a receivable.
What this package is NOT
- Not REST. REST solves networks; there is no network here. Verbs, routes, status codes and JSON would be pure overhead. A method call with a typed DTO gives the same decoupling and lets PHP check the types for you.
- Not a dependency injection container. lliure has its own lifecycle; pulling in Symfony DI or Laravel Container would mean importing a framework inside another one.
- Not a replacement for a public HTTP API. Integration with outside systems stays on HTTP, which is where it earns its cost.
- Not a cross-app transaction. Separate databases, no two-phase commit: if the second step fails the user retries, and idempotency protects them.
- Not a message queue. The bus delivers in-process, synchronously, once. No persistence, no retry, no delivery across requests. If work must survive a crash or run later, it belongs in a job table, and the listener's job is to put it there.
Versioning
A published interface is a contract. A breaking change never edits the existing
interface — it creates WritesReceivableV2, and the registry returns both until
every app has migrated. Keeping two interfaces around for a while costs far less
than coordinating a same-day deploy across two dozen applications.
The same goes for events, with one addition: an event may gain optional fields, never lose or repurpose one. A listener written against the old shape has to keep working, because you do not get to deploy it on the same day.
Tests
php tests/contracts.php
php tests/finance.php
php tests/events.php
No database, no framework, no composer install required: the apps are fakes
and the autoloader is a three-line PSR-4 stub in tests/bootstrap.php.