This repository is the framework itself. To build something with it, start from amsrafid/piper by running
composer create-project amsrafid/piper my-api. That gives you the directory layout, the config files and the console script, and pulls this in as a dependency. Everything below is what you get once it's there.
A small PHP framework for building APIs. I wrote it in 2022, on my own, to learn what a framework has to do. Routing, a container, middleware, migrations, a token guard and a code generator, each one built rather than read about.
It still runs. In 2026 I went back through it, fixed what was broken and added attribute routing. The commit history has both halves in it.
How it differs from a smaller Laravel: you don't have to declare your routes. A controller method is reachable as soon as you write it, and you declare a route only when you want something the convention can't give you.
class ShopController extends Controller
{
public function index() { } // GET /shop/index, nothing declared
#[Get('/shop/{slug}')] // declared, because a slug is not a convention
public function bySlug($slug) { }
}PHP 8.0, 8.1, 8.2 or 8.3. The composer.json says >=8.0 <8.4 and means it. On 8.4 the
deprecations inside illuminate/database 8.x fail, not Piper's own code.
Three ways to reach a controller method. They work together, and later ones win.
The URI maps to a controller and a method. App\Http\Controllers\ShopController::index() answers
/shop/index, and numeric segments after that become arguments.
Nothing to register. This is what you get by writing a method.
The convention has one limit worth knowing: a segment only becomes an argument if it is numeric.
/shop/42 reaches index(42), but /shop/blue-cap looks for a Shop\BlueCapController. That is
what attributes are for.
use Piper\Routing\Attributes\Get;
use Piper\Routing\Attributes\Middleware;
use Piper\Routing\Attributes\Post;
use Piper\Routing\Attributes\Prefix;
use Piper\Routing\Attributes\Route;
#[Prefix('/api/v2')]
#[Middleware('auth:api')]
class OrderController extends Controller
{
#[Get]
public function index() { } // GET /api/v2/order/index
#[Get('/orders/{slug}/lines/{line}')]
public function lines($line, $slug) { } // parameters arrive by name, in any order
#[Get('/orders/{id:\d+}')]
public function show($id) { } // constrained
#[Get('/orders/page/{number?}')]
public function page($number = 1) { } // optional
#[Post('/orders')]
#[Middleware('throttle')]
public function store(Request $request) { } // type hints still come from the container
#[Route('/health', methods: ['GET', 'HEAD'])]
public function health() { }
}#[Get] #[Post] #[Put] #[Patch] #[Delete] #[Options] #[Head] and the generic #[Route]
all take an optional path. Leave the path out and the convention still supplies it. All of them are
repeatable, so one method can answer more than one path.
Placeholders: {id}, {id?}, {id:\d+}, {id?:\d+}. The ? goes before the :, because
{id:\d+?} would be ambiguous. ? is a regex quantifier too.
#[Prefix] on the class prefixes every route in it, including the convention-derived ones. Note
that it stacks with the namespace. A controller in App\Http\Controllers\Api that also declares
#[Prefix('/api/v2')] ends up under /api/v2/api/..., so put the controller where you want it or
set the whole path explicitly.
#[Middleware] works on the class and on the method. It merges with the kernel's global and prefix
middleware instead of replacing it.
public function __construct()
{
$this->post('store'); // restrict a method to a verb
$this->middleware('auth'); // controller-wide
$this->middleware(['auth:api' => 'store']);
}Still supported, still works. An attribute on the same method wins.
Global and prefix middleware live in the application's app/Http/Kernel.php:
protected $middleware = [
\Piper\Foundation\Middleware\KeyValidation::class,
];
protected $prefixMiddleware = [
'/api' => [\Piper\Authentication\Middleware\ApiDefault::class],
];
protected $routeMiddleware = [
'auth:api' => \App\Http\Middleware\AuthApi::class,
];A middleware receives the request and the next handler. Returning a response instead of calling
$next stops the request there.
php amsrafid make:controller Shop
php amsrafid make:middleware CheckAge
php amsrafid make:model Order
php amsrafid make:migration create_orders_table
php amsrafid migrate
php amsrafid migrate:rollback --file=create_orders_table
php amsrafid migration:status
php amsrafid make:service-provider Payment
php amsrafid storage:link
Eloquent, through illuminate/database. Models extend Piper\Database\Model, migrations are plain
classes with up() and down(), and the connection is configured in config/database.php and
env.xml.
A JWT guard with pluggable drivers and providers, configured in config/auth.php. $this->auth()
inside a controller gives you the guard.
Worth knowing before you pick it up:
- No view layer. It returns API responses. The 2022 README said a view layer was coming in the next version. It never got written, and this is an API framework.
- No route cache yet. The scanner reflects over your controllers on each request that reaches
the dispatcher. That is a handful of files and no database work, but on a large application you
will want
route:cache. It is the next thing on the list. - No
route:listyet. The table exists and is complete. The command that prints it comes with the cache. - Apache assumed. The framework itself reads nothing Apache-specific, but the starter
application routes through a
public/.htaccess, so nginx with FPM needs the same rewrite written by hand. - One application per install. No multi-tenancy, no package discovery, no events.
composer install
composer testThe suite is small on purpose. It covers the parts that break quietly:
| File | What it holds |
|---|---|
BootTest |
the application boots, and every binding the framework makes for itself resolves |
AttributeRouteTest |
the scanner, the table and the matcher, through a real request |
ConventionRouteTest |
a controller that declares nothing is still routed by convention |
ErrorResponseTest |
with debug off, only deliberate 4xx exceptions report a message |
Each test boots a fixture application under tests/Fixtures/App, so the boot path is the same one
a front controller takes. CI runs the suite on 8.0, 8.1, 8.2 and 8.3.
Not covered yet: migrations, an authenticated request and the console commands. Those need a database and an HTTP server rather than a fixture, and they are next.
Issues and pull requests are welcome. Run composer test before opening one. The framework is
expected to boot, serve and run its console on 8.0 through 8.3.
A. M. Sadman Rafid, amsrafid.com · github.com/amsrafid
If you find a security problem, please email amsrafid@gmail.com rather than opening a public issue.
MIT.