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.