Skip to main content
A Solution is a vertical product module — HR, recruitment, anything with its own domain behaviour — distributed as a Composer package under your own vendor.
The external Solution lifecycle is planned, not product-complete in Core. The boundaries below are enforced today; the installation and release tooling around them is still being built. Treat this page as the contract you will be held to, not as a finished walkthrough.

Boundaries

A Solution must not live under app/Solutions inside Core. It is an external package, resolved through Composer like any other dependency — see Development setup for the two-lane workflow. Its public contracts belong in fapost/foundation. Primitives with no Core dependency belong in fapost/support. Neither package may reference App\…, and neither may absorb business logic that belongs to Core. Front-end components are possible, but only through the agreed build and publish contract. A Solution cannot drop Vue files into a running application — the builder front end is compiled ahead of time.

What a Solution typically contains

Most Solutions are a combination of pieces documented elsewhere in this section:

Node handlers

So flows can act on your domain.

A data accessor

So conditions can read your data without duplicating it.

Builder configuration

So your nodes can be configured on the canvas.

Migrations

Subject to the same migration isolation rules as Core.

Limiting what a tenant can create

If your Solution has records an operator should be able to cap per tenant (for example candidates), register a limit key from your service provider’s boot(). Core binds the registry in its own provider, which can run after a package’s register(), and closes the registry once the application has booted, so do not register from register() or from a booted() callback:
The key is snake_case and stable, because plans store limits under it; the label and unit are in English. An operator package reads the registry to offer the limit in its plan form, and answers how much each tenant may have through TenantLimitsInterface. Registering the key is the Solution’s whole contribution to the contract: it does not decide the number. Registering the key makes the limit exist; enforcing it is your Solution’s job. Create the records through one service that counts the tenant’s existing ones and asks RecordQuotaInterface before saving, and send every creation path through that service:
It throws RecordLimitReachedException with a message fit for the person who sees it; catch it where you show errors. It works in the tenant context of the current request or job. Records that already exist stay when a limit drops: only creating more is refused. See Foundation contracts for the interfaces.

Limiting how much a tenant does in a period

For work that is counted over time rather than stored, such as messages sent or calls made, register the key from boot() the same way with kind: LimitKind::PerPeriod. Nothing is stored in a tenant schema; the operator package counts. Spend one unit before the work, with a natural key that is the same on every retry, and do the work only when the answer is allowed:
Core wraps this call to fail open when the operator’s implementation throws; your code is calling the interface directly, so catch Throwable around it, report it and let the work through, which is what Core does. Build the UsageUnit before anything irreversible, and never put a personal identifier in the unit key. See Foundation contracts for what the implementation promises.

Before you start

Read Choosing an extension type first. The most common mistake is building a Solution for something that has no domain of its own and should have been a Plugin — or, more expensively, building one for something that belongs in Core.