Documentation
Create an idea
Build, test, and reuse your own Stage extension with Composer.
Build a greeting endpoint that you can install in another Stage application. The example uses PHP 8.4 or later within PHP 8, Composer 2, and Stage 0.1.5.
An HTTP idea is a PHP object that implements Stage\Http\Idea. Its routes()
method returns the routes it provides. Your application constructs the idea
and passes it to Application.
Create the package
In a working directory, run:
mkdir greeting-idea
cd greeting-idea
mkdir src public tests
Create composer.json:
{
"name": "acme/greeting-idea",
"description": "A reusable greeting endpoint for Stage.",
"type": "library",
"license": "MIT",
"require": {
"php": "^8.4",
"skyyware/stage": "^0.1.4"
},
"autoload": {
"psr-4": {
"Acme\\Greeting\\": "src/"
}
}
}
acme/greeting-idea is an example name. Choose your own vendor name before
sharing the package. Composer's PSR-4 mapping loads classes from src/ when
their namespace begins with Acme\Greeting\.
Define the idea
Create src/Greeting.php:
<?php
declare(strict_types=1);
namespace Acme\Greeting;
use Stage\Http\Idea;
use Stage\Http\Request;
use Stage\Http\Response;
use Stage\Http\Route;
final readonly class Greeting implements Idea
{
public function __construct(private string $greeting) {}
/** @return list<Route> */
public function routes(): array
{
return [
Route::get('/hello/{name}', fn (Request $request) =>
Response::json([
'message' => $this->greeting . ' ' . $request->parameters['name'],
])),
];
}
}
The constructor accepts configuration. You can pass services through the constructor in the same way. The host application chooses those dependencies.
Stage reads routes() when it constructs Application. Keep database writes,
migrations, and other work out of this method. The route handler runs when a
request matches /hello/{name}.
Connect it to an application
Create public/index.php:
<?php
declare(strict_types=1);
use Acme\Greeting\Greeting;
use Stage\Http\Application;
use Stage\Http\Response;
use Stage\Http\Route;
require dirname(__DIR__) . '/vendor/autoload.php';
(new Application(
Route::get('/health', fn () => Response::json(['status' => 'ok'])),
new Greeting('Hello'),
))->run();
Individual routes and ideas can share an application. Add another idea by
passing another object to Application. Each combination of method and path shape
must be unique. Registering this greeting idea twice throws InvalidArgumentException
during construction, before the application handles a request.
Install the dependency and start the local development server:
composer install
php -S 127.0.0.1:8080 -t public public/index.php
In another terminal, call the endpoint:
curl http://127.0.0.1:8080/hello/Sam
The response is {"message":"Hello Sam"}. Changing the constructor argument
to 'Welcome' changes the response to {"message":"Welcome Sam"}.
/health returns {"status":"ok"}.
The built-in PHP server is for local development. Serve only public/ when
you deploy the application.
Test without a server
Create tests/greeting.php:
<?php
declare(strict_types=1);
use Acme\Greeting\Greeting;
use Stage\Http\Application;
use Stage\Http\Request;
require dirname(__DIR__) . '/vendor/autoload.php';
$app = new Application(new Greeting('Hello'));
$cases = [
['GET', '/hello/Sam', 200, '{"message":"Hello Sam"}'],
['POST', '/hello/Sam', 405, ''],
['GET', '/missing', 404, 'Not found'],
];
foreach ($cases as [$method, $path, $status, $body]) {
$response = $app->handle(new Request($method, $path));
if ($response->status !== $status || $response->body !== $body) {
throw new RuntimeException($method . ' ' . $path . ' failed');
}
}
echo "3 checks passed\n";
Run php tests/greeting.php. The output is 3 checks passed. These checks
cover a successful request, a method the route does not accept, and an unknown
path. handle() returns a response without sending headers or output.
HEAD and OPTIONS work as they do for individual routes. handle() retains the
GET response body for HEAD; run() suppresses that body when sending it.
See the HTTP reference for routing and error behavior.
Reuse it in another application
Stop the development server with Ctrl+C. From greeting-idea/, create a
neighboring application and install the local package:
mkdir ../example-app
cd ../example-app
composer init --name=acme/example-app --type=project --no-interaction
composer config repositories.greeting path ../greeting-idea
composer require 'acme/greeting-idea:@dev'
mkdir public
cp ../greeting-idea/public/index.php public/index.php
php -S 127.0.0.1:8080 -t public public/index.php
Call /hello/Sam again. The response is still {"message":"Hello Sam"}.
This application loads Greeting through Composer instead of defining its
own copy. Composer's path repository
connects the two local directories. The @dev constraint permits the
unreleased example package for local development.
To distribute a release, give the package its own repository. Include the
actual MIT license text, tests, a changelog, and a README with its constructor
inputs, routes, dependencies, and supported versions. Tag a release and make
it available through Packagist or your private Composer repository. Consumers
can then replace the local path repository and development constraint with
the released package version. Commit the consuming application's
composer.lock.
Choose other extension points when needed
Use ordinary PHP services for reusable behavior that does not own HTTP routes. For an operation shared by HTTP, CLI, and agents, follow shared operations. For editable content, use the page types, themes, and publication rules described in the Stage CMS guide.
An idea does not create an authentication system or run an agent. This greeting route is public. Add identity and permission checks before exposing protected operations. Installed Composer libraries execute trusted PHP code; review them before installation. Stage does not discover or activate packages from content.
The Stage 0.1.4 idea contract and Composer's PSR-4 rules describe the interfaces used here.