Documentation
Shared operations
One set of rules for HTTP, commands, and agents.
Build a counter and a report in your application. The counter owns input and permission checks. The report receives a contract exposing only the read operation.
Start with the Composer application. Run all commands from your application directory.
Register your application's classes
Add an autoload section alongside require in your composer.json.
Keep any other dependencies and settings your application already has:
{
"require": {
"skyyware/stage": "^0.1"
},
"autoload": {
"psr-4": {
"App\\": "src/"
}
}
}
Create the feature directories and regenerate the autoloader:
mkdir -p src/Features/Counter/Contract src/Features/Reports bin
composer dump-autoload
The App\ prefix maps your application's classes to files under src/.
Define the input and contract
Create src/Features/Counter/Increment.php:
<?php
declare(strict_types=1);
namespace App\Features\Counter;
use InvalidArgumentException;
final readonly class Increment
{
public function __construct(public int $amount)
{
if ($amount < 1 || $amount > 100) {
throw new InvalidArgumentException('Increment must be between 1 and 100.');
}
}
}
Create src/Features/Counter/Contract/ReadCount.php:
<?php
declare(strict_types=1);
namespace App\Features\Counter\Contract;
use Stage\Security\Caller;
interface ReadCount
{
public function read(Caller $caller): int;
}
Keep permissions inside the operation
Create src/Features/Counter/Counter.php:
<?php
declare(strict_types=1);
namespace App\Features\Counter;
use App\Features\Counter\Contract\ReadCount;
use Stage\Security\Caller;
final class Counter implements ReadCount
{
private int $value = 0;
public function increment(Increment $input, Caller $caller): int
{
$caller->require('counter.write');
return $this->value += $input->amount;
}
public function read(Caller $caller): int
{
$caller->require('counter.read');
return $this->value;
}
}
The operations check permissions before reading or changing the count. A call from HTTP, a command, or an agent adapter follows the same rule.
Compose another feature through the contract
Create src/Features/Reports/Report.php:
<?php
declare(strict_types=1);
namespace App\Features\Reports;
use App\Features\Counter\Contract\ReadCount;
use Stage\Security\Caller;
final readonly class Report
{
public function __construct(private ReadCount $counter) {}
public function total(Caller $caller): int
{
return $this->counter->read($caller);
}
}
Run successful, denied, and malformed calls
Create bin/counter.php:
<?php
declare(strict_types=1);
use App\Features\Counter\Counter;
use App\Features\Counter\Increment;
use App\Features\Reports\Report;
use Stage\Security\Caller;
use Stage\Security\Forbidden;
require dirname(__DIR__) . '/vendor/autoload.php';
$counter = new Counter();
$writer = new Caller('writer', ['counter.read', 'counter.write']);
$reader = new Caller('reader', ['counter.read']);
$counter->increment(new Increment(2), $writer);
$report = new Report($counter);
echo $report->total($reader), PHP_EOL;
try {
$counter->increment(new Increment(3), $reader);
} catch (Forbidden) {
echo "Write denied", PHP_EOL;
}
try {
$counter->increment(new Increment(0), $writer);
} catch (InvalidArgumentException) {
echo "Invalid amount", PHP_EOL;
}
echo $report->total($reader), PHP_EOL;
Run the command:
php bin/counter.php
Expect:
2
Write denied
Invalid amount
2
Both rejected calls leave the count unchanged.
Apply the pattern to your application
Supply callers from your trusted authentication adapter. The fixed callers
above are local examples. A Caller value does not authenticate a person or
agent. Never accept a permission list from an untrusted request. Permissions
are exact strings; they do not support wildcards. Check resource and tenant
access inside the owning operation as well.
In an HTTP handler, catch Forbidden and return a 403 response. Application
does not automatically translate this exception; an uncaught Forbidden
reaches the generic 500 response from run. Keep HTTP translation in the
adapter so the operation remains usable from other entry points.
To check feature dependencies in your application, adapt the framework's Deptrac configuration to your namespaces. Install the tool as an application development dependency and test a forbidden import. The framework's boundary test shows that check. Static analysis does not prevent shared-database access or reflection.
The example stores its count in memory for one command invocation. It does not provide persistence, transactions, concurrency, or tenant storage. Create request-specific mutable objects per request. An immutable router does not make a mutable object captured by a handler safe to share in a worker.
Read the design for the core's responsibilities and limits.
Version and source
This guide targets Stage 0.1.4. Read the versioned source.