Skip to content

Released Services ​

since 3.0

A module that other modules build on offers them two things beyond its screens: operations — the smallest building blocks of a mutation, one verb each, which a caller composes in its own unit of work, such as posting a document — and services — facades of several methods for any purpose: reading, aggregating, recording, writing, such as the configuration in force on a date. Both are released: other modules code against them, and they are under SemVer.

A released service is an artifact like a command or a job. It has no route and never reaches the browser, and it is not bound in the container — the registry hands it out. It is three classes:

  • a declaration class, listed in the module's ARTIFACTS, which says what the service is;
  • a plain PHP interface, which says what it does — what callers code against;
  • a final handler class that implements it, named in the declaration.

Declare an offered operation ​

An operation is one verb: its interface has exactly one method.

php
// {Domain}/Contracts/PostDocumentInterface.php
interface PostDocumentInterface
{
    public function handle(PostingRequest $request): PostingReference;
}

// {Domain}/Services/PostDocument.php
use LaravelUi5\Sdk\Ui5\AbstractOperation;

final class PostDocument extends AbstractOperation
{
    public const string NAMESPACE   = 'com.example.accounting.services.post_document';
    public const string TITLE       = 'Post a document';
    public const string DESCRIPTION = 'Takes a posting over from a module and records its origin.';
    public const string INTERFACE   = PostDocumentInterface::class;
}

An operation runs in its caller's unit of work. It opens no transaction and seals nothing: the caller's trigger — an action, a command, a job — owns the transaction and the change log's head.

Declare a service ​

A service may carry several methods, and what they do is its own business — read, aggregate, record, write. When it writes, it follows the rules of an operation: it runs in the caller's unit of work, opens no transaction and seals nothing.

php
use LaravelUi5\Sdk\Ui5\AbstractService;

final class Configuration extends AbstractService
{
    public const string NAMESPACE   = 'com.example.configuration.services.configuration';
    public const string TITLE       = 'Configuration';
    public const string DESCRIPTION = 'Reads the characteristics in force on a date.';
    public const string INTERFACE   = ConfigurationInterface::class;
    public const string HANDLER     = ConfigurationHandler::class;
}

The base class is the kind: AbstractOperation (one verb, composable) or AbstractService (a facade). The declaration carries no VERSION — it runs under its module's.

Register it ​

List the declaration in the module's ARTIFACTS. Bind nothing: the registry builds the HANDLER through the container — its constructor injected like any class — and puts the mediator in front. The handler is final, and the registry checks when it loads that it exists and implements the interface.

php
// {Domain}/PostDocumentHandler.php
final class PostDocumentHandler implements PostDocumentInterface
{
    public function __construct(private Ledger $ledger) {}

    public function handle(PostingRequest $request): PostingReference { /* … */ }
}

final class AccountingModule extends AbstractUi5Module
{
    public const array ARTIFACTS = [
        Services\PostDocument::class,
        // …
    ];
}

ui5:sync records the declaration like every artifact. It shows in no navigation and no palette.

Keep the interface out of the container. A binding of a released interface would open a second way to the service, past the mediator; php artisan ui5:doctor reports one as an error (sdk.W6).

A released service is not replaced ​

A released service is what a domain offers others; nobody swaps how a module posts its documents. What is meant to be replaced — a check the host tightens, a format a package fills — is an extension point: a plain Laravel service, an interface with a default in the container.

php
// the owner: a default another package or the host may replace — bindIf, so a replacement wins whatever the order
$this->app->bindIf(CreditCheckInterface::class, DefaultCreditCheck::class);

// the host, whose providers register last
$this->app->bind(CreditCheckInterface::class, StrictCreditCheck::class);

Laravel registers package providers in the alphabetical order of vendor/composer/installed.json, so between two packages the order says nothing: a default bound with bindIf leaves a replacement standing that registered before it.

Guard it ​

An operation is always guarded: #[Call] on its declaration is mandatory, and the registry refuses an operation without it. On a service #[Call] is optional: without it the service is called freely, with it the mediator checks the caller first. A service that writes carries #[Call]. #[Call] takes the arguments of #[Act] — the ability, its role, a note — and its titles come from the bundle as abilities.call.<ability>.title and .description.

php
use LaravelUi5\Sdk\Security\Attributes\Call;

#[Call('postDocument', AccountingRole::Clerk, note: 'Post a document from another module.')]
final class PostDocument extends AbstractOperation { /* … */ }

#[Call] on anything but an operation or a service fails when the registry loads, and so does #[Read] on either: #[Read] is the read gate of an OData entity set, nothing else.

Use a service from another module ​

Declare on your module which foreign services it calls, and take each one from the registry — a released service is an artifact, and artifacts come from the registry. The version constraint is your package's Composer require, not the attribute.

php
use LaravelUi5\Sdk\Weave\Services\Attributes\Uses;
use LaravelUi5\Sdk\Weave\Services\Contracts\ServiceRegistryInterface;

#[Uses(PostDocumentInterface::class)]
final class BillingModule extends AbstractUi5Module { /* … */ }

final class InvoiceBooker
{
    public function __construct(private ServiceRegistryInterface $services) {}

    public function book(PostingRequest $request): PostingReference
    {
        return $this->services->getService(PostDocumentInterface::class)->handle($request);
    }
}

What getService() returns is not the handler itself but the service mediator in front of it: an object that implements the interface and, on every call, checks the ability against the actor of the current SdkContext before it hands the call on. So a guarded service needs a context — call it from a request or from an audited run (an action, a command, a job); without one it throws.

The registry is the only way in. The interface is not bound, so injecting PostDocumentInterface fails — the container cannot build an interface. Inside its own domain the owner uses its handler directly, unguarded: #[Call] guards the border between domains, not a domain's inside.

When the actor lacks the ability, the mediator writes a CallRefused row to the security log and throws ServiceCallRefusedException into your code; in a unit of work the unit rolls back, and the row stays — it is written once the transaction is over.

#[Uses] names every foreign service the module calls — operations and services alike. The registry refuses at load an interface no module declares as a service (a typo, or a package you did not require), a service of your own module (call your handler directly) and the same interface twice.

php artisan ui5:describe lists a module's operations and services and what it #[Uses].

The registry maps each interface to exactly one declaration; two declarations of one interface fail when the registry loads. ServiceRegistryInterface::service() returns the declaration itself, uses() what a module declares it calls.

Release an event ​

An event other domains may listen to is part of the contract too. Mark it #[BusinessEvent]; an event without the attribute stays your domain's own.

php
use LaravelUi5\Sdk\Weave\Services\Attributes\BusinessEvent;

#[BusinessEvent]
final class DocumentPosted { /* … */ }

Release a read surface ​

Services and analytic sets answer in the owner's terms. What they cannot do is a join: a list of invoices with the debtor's name and town, filtered and sorted by them, is one query over a thousand rows, not a service call per row. For that a model releases its read surface — the columns and relations other modules may read, join, filter and expand, with a SemVer guarantee:

php
use LaravelUi5\Sdk\Weave\Services\Attributes\ReadSurface;

#[ReadSurface(
    columns:   ['id', 'ext_ref', 'type', 'name', 'email', 'phone'],
    relations: ['addresses'],
)]
class Partner extends Model { /* … */ }

What the attribute names is contract: a named column or relation that goes, is renamed or changes type is a major, a new one a minor. Everything else is internal — the columns it does not name, the model's write surface (creating, updating and deleting belong to the owner's operations), and every model without the attribute. A released relation leads to a model with a read surface of its own. ui5:describe lists a package's read surfaces.

changed in 3.0Before 3.0 every model of a DB package was contract, write surface included.

The SDK releases the read surface of Partner, PartnerAddress, PartnerRelationship and PartnerRelationshipType.

Scaffold it ​

bash
php artisan ui5:service Accounting/PostDocument --role=clerk   # an offered operation, #[Call('postDocument', 'clerk')]
php artisan ui5:service Accounting/PostDocument --role=clerk --call=bookDocument
php artisan ui5:service Accounting/Configuration --service     # a service, called freely
php artisan ui5:service Accounting/Dunning --service --role=clerk   # a service guarded by #[Call]

The command writes the declaration to Services/, the interface to Contracts/ and the final handler beside them; an operation needs --role, since its #[Call] is mandatory, and a service gets #[Call] when you give one. It prints the ARTIFACTS line, the #[Uses] and getService() lines for callers and the bundle keys — the title's (services.<namespace> and .description) and an operation's ability. There is nothing to bind.

What counts as a breaking change ​

ChangeRelease
a new service, a new released eventminor
a method's signature changes, a method or a service goes, an event's payload changesmajor
a new method on a released interfacemajor — every implementer breaks; give the new need an interface of its own
an ability renamed, #[Call] added to a servicemajor — a security change for every caller

Not there yet ​

With the first module built on the Foundation come the checks that bind only such modules: that a unit of work writes another domain's tables only through an operation its module #[Uses], and the doctor's warnings against reading or writing a foreign domain past its released surface.

See also ​

  • LUX Weave: concepts, front doors and doorways between apps
  • Units of Work: the trigger, the unit and the operation