padosoft / laravel-routines-contracts
Shared contracts for padosoft/laravel-routines — the vocabulary a package needs to declare a routine target, with zero runtime dependencies.
Package info
github.com/padosoft/laravel-routines-contracts
pkg:composer/padosoft/laravel-routines-contracts
Requires
- php: ^8.3
Requires (Dev)
- laravel/pint: ^1.18
- pestphp/pest: ^3.0
- phpstan/phpstan: ^2.0
README
The vocabulary a package needs to declare a routine target, with zero runtime dependencies.
This package is the shared language between padosoft/laravel-routines
— the engine that decides when something runs and on whose behalf — and the packages that know
how to run it.
It contains interfaces and value objects. No service provider, no migrations, no framework. A package that wants to be a routine target depends on this, not on the engine that will invoke it.
Install
composer require padosoft/laravel-routines-contracts
Requires PHP 8.3+. Nothing else.
The one interface that matters
use Padosoft\Routines\Contracts\Target\RoutineTarget; final class SendDigestTarget implements RoutineTarget { public function type(): string { return 'digest'; } public function descriptor(): TargetDescriptor { /* how the UI draws your form */ } public function validate(array $payload): ValidationResult { /* at CREATION, not at 3am */ } public function fire(RoutineExecution $execution): TargetResult { /* do the thing */ } }
Register it from your own service provider. The engine keeps no list of target types — it asks its registry, and the registry only knows what packages have put in it.
This is the same shape as ReviewableSource in
laravel-iam-server: whoever orchestrates and
whoever knows the domain are two different responsibilities, and keeping them apart is what lets an
optional package join without the core learning about it.
Four outcomes, and why the fourth exists
fire() returns a TargetResult carrying one of four outcomes. Three are obvious.
TargetResult::succeeded('Sent 14 digests', cost: 0.08); TargetResult::failed('SMTP refused the connection'); TargetResult::skipped('Nothing new since the last run'); TargetResult::paused('Needs approval to charge €250', pendingApprovalId: 'apr_1');
paused is the one that carries the design. A routine runs when the user is not there. If it
meets something that exceeds the mandate it was created with, neither of the other answers is
honest: nothing is broken, so it did not fail; nothing was done, so it did not succeed. It is
stopped, and a human is needed.
Without that fourth case, whoever implements a target has to choose between proceeding without permission and failing silently — and both are worse than asking.
The mandate
RoutineMandate is the piece that makes unattended execution defensible.
The ecosystem's security rule says the model proposes and the user confirms on screen, per action. A routine breaks that by definition: at 3am there is no screen. The usual workarounds are both bad — ask for one broad delegation and let it run (the "let the assistant act for me" switch, with a nicer name), or never run anything useful.
The mandate is the third way: a standing consent that is narrower than the interactive one. The step-up confirmation is cryptographically bound not to a transaction but to four things at once:
$mandate = new RoutineMandate( targetType: 'flow', payloadDigest: hash('sha256', $canonicalPayload), actionClasses: ['orders.read', 'orders.draft'], // what it may do unattended budgetCeiling: 25.00, notAfter: new DateTimeImmutable('+90 days'), ); $mandate->digest(); // what the consent signs — change any of the four, it changes $mandate->covers('orders.pay'); // false → the routine pauses and asks a human
Inside those four the routine acts alone. Anything that exceeds them pauses the run and reaches a human on a channel, with step-up bound to the real parameters of that action.
Two deliberate details, both tested:
- The digest is canonical. Reordering or duplicating action classes does not change it, and
10and10.00are the same ceiling. Two semantically identical mandates must produce the same digest — otherwise an innocent save would invalidate a valid consent, and the user would be asked again and again, which is the fastest way to teach someone to confirm without reading. covers()is fail-closed. A mandate with no action classes authorizes nothing. An empty array meaning "everything" would turn an oversight into full permission.
What's in the box
Target\RoutineTarget |
The interface. Four methods. |
Target\TargetDescriptor |
How a target presents itself to a UI that has never heard of it. |
Target\TargetResult · TargetOutcome |
The four outcomes, with cost and resume token. |
Target\ValidationResult |
Payload errors, shaped for a form. |
Execution\RoutineExecution |
The immutable per-fire context: idempotency key, delegated token, budget left, deadline, timezone. |
Execution\FireReason |
Scheduled · manual · catch-up · event · webhook · retry · resumed. |
Routine\RoutineRef · RoutineStatus · TriggerKind |
Identity and lifecycle. |
Routine\OverlapPolicy · MissedRunPolicy |
What to do when a fire is still running, or was missed. |
Consent\RoutineMandate · MandateExceeded |
The standing, bounded consent. |
Design notes worth knowing
The target never invents its own idempotency key. It arrives in RoutineExecution, generated
once at fire time and stable across retries. A target that generates its own generates a different
one on every retry — which is exactly the bug the key was there to prevent. Same for the delegated
token, the remaining budget and the deadline: the engine decides, the target receives.
type() is stable forever. It is persisted on every routine and every run. Changing it orphans
history, which is evidence: past routines would point at a type that no longer exists, becoming
neither runnable nor readable.
Owners are canonical strings, not objects. RoutineRef::$owner is user:usr_123, not a
SubjectRef from another package. These contracts depend on nothing, and whoever integrates IAM
converts at the boundary. Duplicating a SubjectRef here would be worse than the dependency: two
incompatible types for the same concept, across an ecosystem that already has one.
Paused and Suspended are not synonyms. RoutineStatus::Paused is the user's choice and only
they undo it; Suspended was imposed by the system — budget exhausted, anomaly detected, owner
disabled — and lifts only when the cause is gone. Merging them would mean a user can cancel a
security suspension by pressing "resume".
Ecosystem
- laravel-routines — the engine: persistence, scheduling, locking, ledger.
- laravel-routines-admin — the panel.
- laravel-iam-agents — delegated identity, so a routine acts for a user with bounded, revocable authority.
- laravel-flow — the workflow engine behind
FlowTarget.
License
MIT. See LICENSE.