migears / web
Minimalist REST framework with file-as-routing
Requires
- php: ^8.1
- psr/container: ^2.0
- psr/log: ^3.0
Requires (Dev)
- phpstan/phpstan: ^2.2
- phpunit/phpunit: ^10.5
Suggests
None
Provides
None
Conflicts
None
Replaces
None
README
A minimalist REST framework with file-as-routing. Zero magic, zero global variables, core code under 600 lines.
Upgrading from 2.2? Path parameters no longer pass through
$this->params/param(): they arrive as constructor parameters. Turn$this->param('user_id')into$this->user_id, and give that resource__construct(private string $user_id) {}, declaring every wildcard its path carries.Three names changed with that: the file that catches the leftover path is now
__catch_remaining__.php, and the leftover path itself arrives as$remainingin the constructor instead of$this->remaining;setRest()andresolve()are gone, so a resource that wants the container types its constructor's first parameterContainerInterfaceand reads entries through$this->container->get($id).
Upgrading from 2.1? This release changes the resource layout and the class naming rule: one file per path (
users.php,users/___user_id___.php) instead of a directory holding anIndex.php, and a resource file's name is now also its class name. Every existingresources/tree has to be renamed — see Routing Rules.
Background: miGears is the open-source successor of TinyGears, a self-developed PHP framework. It was renamed and open-sourced recently because the name TinyGears is already taken in the open-source community.
Features
- File-as-routing — A file's path is the endpoint it answers, no routing table configuration needed
- Lightweight Request/Response — Custom objects, simpler and more intuitive than PSR-7
- PSR-3 / PSR-4 / PSR-11 / PSR-12 — Follows logging, autoloading, container, and coding standards
- Minimal dependencies — Only
psr/containerandpsr/log - Under 600 lines of code — Comments and blank lines excluded, so it stays true as the documentation grows; read the entire framework in one sitting
- No global variables, no process-wide static singletons — Fully testable and injectable; the container is per-instance, and an entry is shared only for the lifetime of that one
MiRest(the lazy singleton described below) - Built-in container, PSR-11 — Register only what needs configuration (PDO, Redis, Logger, DAOs, Managers); everything else is just
new before()/after()hooks — Lightweight middleware alternative___param___wildcard segments — Capture URL segments as named parameters
Boundaries
In scope
- File-as-routing and dispatch:
ResourceLocatorwalks the filesystem (<segment>.php/___param___/__catch_remaining__.php), andMiRest::handle()wires global before/after hooks, the 404 and error handlers, and the template methodbefore→ HTTP verb →after. - The lightweight
Request/Responseobjects (Request::fromGlobals(),Response::json/html/redirect/empty,send()) andAbstractResource's HTTP verb handlers, including theOPTIONS/HEADdefaults and the405response with anAllowheader. - The PSR-11 container that
MiRestitself is (set()/has()/get(), lazy-singleton factories) plus the container a resource asks for through its constructor — it is the composition root that wires the sibling modules together.
Not in scope (by design)
- Persistence / data access — no database connection, SQL builder or DAO;
PDOis only ever registered as an opaque container entry. Owned bymigears/dao/migears/sql/migears/mitable. - Business logic and orchestration — managers, the event bus and domain models come from
migears/manager/migears/domain; this package only calls the resource handler it dispatches to. - Rendering / templating —
Response::html()merely carries an HTML string; view compilation and layout rendering belong tomigears/pages/migears/template. - Logging, caching, auth, mail, image and i18n — each is a sibling module (
migears/log/migears/cache/migears/security/migears/mail/migears/image/migears/i18n); this package only resolves a PSR-3LoggerInterfacefrom the container and implements none of them.
Installation
composer require migears/web
Requires: PHP 8.1+, psr/container, psr/log.
Quick Start
1. Create Resource Directory
resources/
index.php # GET /
users.php # GET /users, POST /users
users/
___user_id___.php # GET /users/{user_id}, PUT /users/{user_id}, DELETE /users/{user_id}
___user_id___/
posts.php # GET /users/{user_id}/posts
docs/
__catch_remaining__.php # catches /docs/a/b — what nothing else answers
2. Write Resource Class
The class name is the file name. The file below lives at
resources/users/___user_id___.php and the bootstrap uses namespace: 'App\\Resources',
so the class is App\Resources\users\___user_id___ — the directories contribute
their names verbatim, with no case conversion. The wildcard on its path arrives as a
constructor parameter.
<?php // resources/users/___user_id___.php namespace App\Resources\users; use MiGears\Web\AbstractResource; use MiGears\Web\Request; use MiGears\Web\Response; class ___user_id___ extends AbstractResource { public function __construct(private string $user_id) {} public function GET(Request $request): Response { $userId = (int) $this->user_id; return Response::json(['id' => $userId, 'name' => 'User ' . $userId]); } public function PUT(Request $request): Response { $userId = (int) $this->user_id; $body = $request->body; return Response::json(['id' => $userId, 'updated' => $body], 200); } protected function before(Request $request): ?Response { // Returning a Response short-circuits dispatch if ($request->header('X-Auth') === '') { return Response::json(['error' => 'unauthorized'], 401); } return null; } protected function after(Request $request, Response $response): Response { // Modify the response and return it return $response->withHeader('X-Powered-By', 'migears'); } }
3. Bootstrap
<?php use MiGears\Web\MiRest; use MiGears\Web\Request; $rest = new MiRest( baseDir: __DIR__ . '/resources', namespace: 'App\\Resources', ); // Register only what needs configuration $rest->set(\Psr\Log\LoggerInterface::class, fn() => new \Psr\Log\NullLogger()); // required; NullLogger for silence $rest->set(PDO::class, fn() => new PDO('mysql:host=localhost;dbname=app', 'user', 'pass')); $response = $rest->handle(Request::fromGlobals()); $response->send();
Container
MiRest is the container: register only what needs configuration (PDO, Redis, Logger, a DAO, a Manager); everything that merely needs new is constructed in place. It is a PSR-11 container — Psr\Container\ContainerInterface — so anything that speaks PSR-11 reaches it without a bespoke interface, and this package does not have to depend on the package that consumes it.
Design philosophy
- Register only what needs configuration — the container exists for objects with setup (DSNs, hosts, credentials). Pure value objects (Request, Response, Domains, ...) are simply
new'd in place. - Closure-based, lazy factories — each entry is a factory closure. It is not executed until first requested, and its result is cached.
- One instance per entry — every entry resolves to one shared instance for the lifetime of the
MiRestobject. - Opt-in, no auto-wiring — the container does no reflection-based auto-wiring and no magical resolution; an entry must be registered explicitly to be used. Explicit beats magical: this keeps the whole container under ~40 lines and instantly readable. (The package uses reflection for three things, all discovery rather than wiring: detecting which HTTP verbs a resource declares — feeding the
OPTIONSdefault and the405Allowheader — reading the type of a resource constructor's first parameter, to see whether it asks for the container, and reading that constructor's declared parameter names, to see whether it asks forremaining.) - No globals, no static — the container lives on the
MiRestinstance and is handed to every resource.
The whole API: three methods
$rest->set(string $id, callable $factory): self // Register a factory (lazy) $rest->has(string $id): bool // Registered (factory pending or resolved)? $rest->get(string $id): mixed // Resolve the instance (cached for reuse)
$id is either a class name (PDO::class) or any custom string ('config'), so interfaces and friendly aliases work naturally. has() and get() are PSR-11; set() is MiRest's own registration side.
Lazy-singleton flow:
$rest->set(PDO::class, fn() => new PDO('mysql:host=localhost;dbname=app', 'user', 'pass')); $rest->has(PDO::class); // true — factory registered (not yet resolved) $rest->get(PDO::class); // 1st call runs the factory, caches the result $rest->get(PDO::class); // 2nd call returns the SAME cached instance $rest->get('missing'); // throws NotFoundException — an unregistered id is an assembly mistake
get() throwing is PSR-11's requirement rather than a style choice: a typo or a forgotten registration must fail where it is asked for, instead of surfacing later as a null that "is not an object". Probing for something optional is what has() is for. NotFoundException is also a RuntimeException, and it is not ResourceNotFoundException — that one means "no resource matched this path" (a 404), this one means "the application was not wired correctly". Where such a failure surfaces depends on who asked: the framework resolves its own logger before dispatch, so a misregistration there escapes handle() as an exception, while an entry a resource asks for is resolved during dispatch and is answered with a 500.
Re-registering with set() replaces the factory and drops the cached instance, so the next get() builds a fresh object — handy for redeploy/rebuild or tests.
The framework's own logger
When the framework reports an internal error — a resource or a global after hook that throws — it writes through a LoggerInterface resolved from the container. There is no built-in default: it is resolved at the start of every handle() call, so an unregistered (or wrong-typed) entry fails loudly on the first request instead of being hidden behind a silent fallback. Registering NullLogger is how you ask for silence:
$rest->set(\Psr\Log\LoggerInterface::class, fn() => new \Psr\Log\NullLogger());
Using the container inside resources
A resource asks for the container by typing its first constructor parameter ContainerInterface, then reaches entries through it directly:
class users extends AbstractResource { public function __construct(private ContainerInterface $container) {} public function GET(Request $request): Response { $pdo = $this->container->get(PDO::class); // shared PDO from the container $logger = $this->container->get(LoggerInterface::class); // and the shared logger $dao = new UserDao($pdo, $logger); // plain `new` — no container needed return Response::json($dao->getAll()); } }
The container is opt-in: a resource that does not declare it never sees it and pays nothing. (The class is users, not Users — the file name is the class name.)
Typical bootstrap wiring:
$rest = new MiRest(baseDir: __DIR__ . '/resources', namespace: 'App\\Resources'); $rest->set(PDO::class, fn() => new PDO('mysql:host=localhost;dbname=app', 'user', 'pass')); $rest->set(\Psr\Log\LoggerInterface::class, fn() => new Monolog\Logger('app')); $rest->set(RedisCache::class, fn() => new RedisCache(new Redis()));
Rule of thumb: needs configuration → register via set().
Needs nothing → just new. Either way your code never depends on container magic.
Note on the
namespaceparameter: Each resource file declares a class whose name is the file name (users.php→class users). Ifnamespaceis empty, all resource classes live in the global scope and will collide as soon as you have more than one resource — you will get a "Cannot redeclare class" fatal error. Always set a namespace for any project with more than one resource file.
Routing Rules
A resource file is named after the path it answers: /users is users.php, and
/users/42 is users/___user_id___.php. Nothing is case-converted — the file
name is the path, and the class name is the file name.
For a request path /foo/bar/baz, the locator descends level by level. At each
level, with foo as the current segment:
foo.php— whenfooends the path, this file answers itfoo/— when more path follows, the directory is entered and matching continues withbar/baz___name___.php— a wildcard file, when the segment ends the path: any segment matches and is captured asname___name___/— a wildcard directory, when more path follows__catch_remaining__.php— answers the current segment and everything after it (two underscores, not three —___name___is the wildcard shape)- 404 — if none of the above match, return 404
Three things can reach a resource's constructor, under different rules:
| Channel | How | When |
|---|---|---|
| The path's captures | by name | always — declare every value the path carries, or the build fails naming the one you left out |
| The container | positionally, first | the first parameter is typed ContainerInterface |
| The leftover path | by name, as remaining |
the constructor declares it |
A resource at users/___user_id___.php declares __construct(private string $user_id) {} and reads $this->user_id; /users/{user_id}/posts is handed user_id too, because the captures of the whole path arrive, not just the last one. Captured segments are raw, so a parameter can only be typed string — validating or casting one is the resource's own job. remaining is a reserved name: a wildcard that captures it as well is reported rather than resolved in silence. A resource that wants none of them declares no constructor at all.
A file and a directory of the same name coexist happily: users.php is the
collection, users/ holds its members.
The remaining path is caught where matching stopped. A __catch_remaining__.php answers the segment that no file or directory claimed, and everything after it — which is how a resource of undetermined depth is written: docs/__catch_remaining__.php serves /docs/a/b/c with $remaining = ['a', 'b', 'c'] in its constructor.
Two edges follow, both worth knowing before placing one:
- A directory is a container, entered to carry the path further. An exact directory is also entered when it ends the path — that is how
/docsreaches the file insidedocs/. A wildcard directory is not, so/api/v1never reaches a catch-remaining file insideapi/___version___/.___version___/is a container for/api/v1/..., not a resource for/api/v1; that depth needs a file (api/___version___.php). - Placed at the root, it catches everything the site does not answer, so
/nonexistentbecomes a 200. Put it at the level you actually want to own.
Finally, the located resource serves the request through the template method handle() (before → HTTP method → after), so hooks run consistently whether or not extra path segments matched.
Note on short-circuiting: If the resource-level
before()returns aResponse, the request short-circuits — neither the HTTP method nor the resource-levelafter()runs. The globalafterhooks registered onMiReststill execute, because they wrap the entire dispatch.
Once located, the HTTP verb decides what runs: the handler the resource declares, or 405 Method Not Allowed when it declares none — the 405 response always carries an Allow header listing the supported methods (RFC 9110). A request whose verb is not an HTTP method gets the same 405 and never calls a handler method, so the resource's own helpers can never be invoked as handlers; note though that the resource is still instantiated and its before() runs before the 405 is decided. The default OPTIONS() reports what the resource supports through an Allow header.
Matching lowercases the URL segment and maps - → _ for exact file/directory lookups, so /Blog-Posts, /blog-posts and /blog_posts all reach blog_posts.php. This lets kebab-case be the recommended URL style on top of PHP's snake_case file names — the locator's nameFor() method, overrideable via subclass, defines the mapping. Wildcard captures and the catch-remaining leftover path are never transformed, because they are user data: /users/john-X-99 captures john-X-99 (original), not john_x_99. . / .. segments are dropped, and a segment carrying a backslash or a NUL byte is refused outright, so the locator can never escape the resource root; baseDir must be an existing directory or an InvalidArgumentException is thrown.
Wildcard vs exact on the same level: an exact foo.php / foo/ always takes priority over a wildcard. If both exist at the same depth, the exact match wins — the wildcard is never reached. This is intentional: explicit routes should not be shadowed by parameter captures.
Route parameters keep their raw URL encoding: values captured from a ___param___ file or directory are passed through as-is (e.g. hello%20world stays hello%20world). Use urldecode() on the constructor parameter when you need the decoded value. This is intentional — keeping raw encoding provides an extra layer of protection against path-traversal variants like %2e%2e.
A file name is a class name, so a segment that is a PHP keyword cannot be a resource:
/newwould needclass new {}, which the parser rejects. Use the plural (/news,/lists,/classes) or another wording — the framework reports that parse error with the file name and the reason.
Keep
resources/outside the document root. The root resource is literally calledindex.php, which is also the web server's default document name; if the directory sits under the docroot, a request for it can be executed directly, bypassing the framework.
Policy Hooks
Two hooks can refuse a request before a handler runs, and they see different things:
MiRest::before() |
a resource's before() |
|
|---|---|---|
| When | before the locator runs, on every request | inside the matched resource, after its constructor parameters are injected and before the verb is checked |
| What it has | the Request |
$request and static::class |
| Endpoint identity | none — the literal path is all it gets | static::class names the endpoint: one key per endpoint, stable across parameter values |
| A path that matches nothing | still passes through it | never reaches it |
So MiRest::before() is the place for policy that does not depend on which endpoint was matched: a maintenance switch, a coarse "everything needs a session unless it is on this list", a bot filter, a shared security header. A decision that is per endpoint belongs in a resource-level before(), because that is the only one of the two that knows which endpoint it is — keying such a rule on $request->path means re-deriving the route the locator already resolved, and a prefix rule then grants the endpoint's children along with it.
abstract class RestrictedResource extends AbstractResource { public function __construct(protected ContainerInterface $container) {} protected function before(Request $request): ?Response { // static::class names the endpoint — ...\users\___user_id___ answers // /users/{id} while ...\users answers /users. The literal path — what an // endpoint-level rule usually keys on — is on $request. $policy = $this->container->get('policy'); if (!$policy->allows(static::class, $request->method, $request->path)) { return Response::json(['error' => 'Forbidden'], 403); } return null; } }
A resource that extends this base class and also takes path parameters declares its own constructor, and has to forward the container — parent::__construct($container) — or before() meets an uninitialised property.
Returning a Response short-circuits, and this hook runs before the verb is checked — a refusal therefore does not tell an unauthorised caller which methods the endpoint supports.
A base class cannot read its subclasses' constructor parameters: those are in scope only inside the resource that declares them. Endpoint-level policy therefore belongs here, keyed on the endpoint; a rule that needs a parameter's value is a row-level rule and belongs in the resource itself.
One caution: a base class is fail-open. A resource that forgets to extend it is silently unprotected, which is the reason to keep the coarse, endpoint-blind default in MiRest::before() and treat the per-endpoint layer as an addition rather than as the only line of defence.
API Reference
MiRest
public function handle(Request $request): Response
Main entry point, dispatches the request to the corresponding resource and returns a response.
$rest->before(callable $handler): self // Global before hook $rest->after(callable $handler): self // Global after hook $rest->notFound(callable $handler): self // Custom 404 handler $rest->error(callable $handler): self // Custom exception handler $rest->set(string $id, callable $factory): self // Register an entry (lazy) $rest->has(string $id): bool // Is it registered? $rest->get(string $id): mixed // Resolve the instance (cached)
Request
$request->method // HTTP method: GET, POST, PUT, DELETE... $request->path // Request path: /users/123 $request->query // Query parameter array $request->body // Parsed body array (JSON/form) $request->headers // Request header array $request->server // $_SERVER array $request->files // Uploaded files array ($_FILES) $request->header(string $name, mixed $default = null): mixed Request::fromGlobals(): self
Response
Response::json(mixed $data, int $status = 200): self Response::html(string $html, int $status = 200): self Response::redirect(string $url, int $status = 302): self Response::empty(int $status = 204): self $response->withHeader(string $name, string $value): self $response->withStatus(int $status): self $response->send(): void
AbstractResource
| Method | Description |
|---|---|
GET() / POST() / PUT() / DELETE() / PATCH() |
HTTP method handlers (override in subclasses). A method you do not override answers 405 Method Not Allowed |
OPTIONS() |
Answers 204 with an Allow header listing what the resource supports (always OPTIONS and HEAD, plus whichever of the five above the subclass declares — detected by reflection, so simply declaring POST() is enough) |
HEAD() |
GET() without a body |
before(Request): ?Response |
Before hook, short-circuits if a response is returned |
after(Request, Response): Response |
After hook, modifies and returns the response |
assertInt(mixed, string): int |
Validate as integer, throws 404 on failure |
License
MIT
migears/web
极简 REST 框架,文件即路由。零魔法、零全局变量,核心代码不到 600 行。
从 2.2 升级? 路径参数不再走
$this->params/param(),改为构造器参数。 把$this->param('user_id')改成$this->user_id,并给该资源加上__construct(private string $user_id) {}——路径上每个通配都要声明。随之改了三个名字:接管剩余路径的文件由
__other__.php改为__catch_remaining__.php, 剩余路径本身也不再是$this->remaining,而是构造器参数$remaining;setRest()与resolve()一并删除,要容器的资源把构造器第一个参数类型写成ContainerInterface, 之后用$this->container->get($id)取条目。
从 2.1 升级? 本次发布改了资源布局与类名规则:一个路径一个文件 (
users.php、users/___user_id___.php),不再是一个目录里放一个Index.php; 资源文件的文件名现在就是它的类名。所有既有的resources/树都必须改名—— 见路由规则。
背景:miGears 源自自研 PHP 框架 TinyGears,因 TinyGears 这一名字 已被开源社区占用,故近期更名并开源发布。
特性
- 文件即路由 — 文件所在的路径就是它所响应的端点,无需配置路由表
- 轻量 Request/Response — 自定义对象,比 PSR-7 更简洁直观
- PSR-3 / PSR-4 / PSR-11 / PSR-12 — 遵循日志、自动加载、容器、编码规范
- 依赖极少 — 仅
psr/container与psr/log - 代码不到 600 行 — 不计注释与空行,因此文档怎么长都不会让这句话失真;一口气读完整个框架
- 无全局变量、无进程级静态单例 — 完全可测试、可注入;容器是每实例的,条目只在那一个
MiRest的生命周期内共享(即下文的懒加载单例) - 内置容器,PSR-11 — 只注册需要配置的部分(PDO、Redis、Logger、DAO、Manager),其他直接
new before()/after()钩子 — 轻量级中间件替代方案___param___通配符段 — 捕获 URL 段作为命名参数
边界
范围内
- 文件即路由与分发:
ResourceLocator逐级遍历文件系统(<segment>.php/___param___/__catch_remaining__.php),MiRest::handle()负责接线全局 before/after 钩子、404 与异常处理器,以及before→ HTTP 动词 →after模板方法。 - 轻量
Request/Response对象(Request::fromGlobals()、Response::json/html/redirect/empty、send()),以及AbstractResource的 HTTP 动词处理器,包括默认OPTIONS/HEAD与带Allow头的405响应。 MiRest本身就是的 PSR-11 容器(set()/has()/get(),懒加载单例工厂),以及资源通过构造器索取的容器 —— 它是把各兄弟模块接线到一起的组合根。
范围外(刻意不做)
- 持久化 / 数据访问 —— 不含数据库连接、SQL 构造或 DAO;
PDO只是被当作不透明的容器条目注册。由migears/dao/migears/sql/migears/mitable负责。 - 业务逻辑与编排 —— Manager、事件总线与领域模型来自
migears/manager/migears/domain;本包只调用它所分发的资源处理器。 - 渲染 / 模板 ——
Response::html()只承载一段 HTML 字符串;视图编译与布局渲染属于migears/pages/migears/template。 - 日志、缓存、鉴权、邮件、图像与国际化 —— 各自都是兄弟模块(
migears/log/migears/cache/migears/security/migears/mail/migears/image/migears/i18n);本包只从容器解析 PSR-3LoggerInterface,绝不实现其中任何一项。
安装
composer require migears/web
要求:PHP 8.1+,psr/container,psr/log。
快速开始
1. 创建资源目录
resources/
index.php # GET /
users.php # GET /users, POST /users
users/
___user_id___.php # GET /users/{user_id}, PUT /users/{user_id}, DELETE /users/{user_id}
___user_id___/
posts.php # GET /users/{user_id}/posts
docs/
__catch_remaining__.php # 接管 /docs/a/b 这类没人认领的路径
2. 编写资源类
类名就是文件名。下面的文件在 resources/users/___user_id___.php,
bootstrap 里用的是 namespace: 'App\\Resources',所以类名是
App\Resources\users\___user_id___ —— 各级目录名原样作为命名空间段,不做任何大小写转换。
路径上的通配以构造器参数传入。
<?php // resources/users/___user_id___.php namespace App\Resources\users; use MiGears\Web\AbstractResource; use MiGears\Web\Request; use MiGears\Web\Response; class ___user_id___ extends AbstractResource { public function __construct(private string $user_id) {} public function GET(Request $request): Response { $userId = (int) $this->user_id; return Response::json(['id' => $userId, 'name' => 'User ' . $userId]); } public function PUT(Request $request): Response { $userId = (int) $this->user_id; $body = $request->body; return Response::json(['id' => $userId, 'updated' => $body], 200); } protected function before(Request $request): ?Response { // 返回 Response 则短路分发 if ($request->header('X-Auth') === '') { return Response::json(['error' => 'unauthorized'], 401); } return null; } protected function after(Request $request, Response $response): Response { // 修改响应后返回 return $response->withHeader('X-Powered-By', 'migears'); } }
3. 启动
<?php use MiGears\Web\MiRest; use MiGears\Web\Request; $rest = new MiRest( baseDir: __DIR__ . '/resources', namespace: 'App\\Resources', ); // 只注册需要配置的服务 $rest->set(\Psr\Log\LoggerInterface::class, fn() => new \Psr\Log\NullLogger()); // 必需;要静默就用 NullLogger $rest->set(PDO::class, fn() => new PDO('mysql:host=localhost;dbname=app', 'user', 'pass')); $response = $rest->handle(Request::fromGlobals()); $response->send();
容器
MiRest 本身就是容器:只注册需要配置的东西(PDO、Redis、Logger、DAO、Manager);只需要 new 的东西就地构造。
它是一个 PSR-11 容器 —— Psr\Container\ContainerInterface —— 所以任何会说 PSR-11 的代码都能取用它,
不必为它另立接口,本包也无需依赖使用它的那个包。
设计哲学
- 只注册需要配置的部分 — 容器存在的意义是有配置需求的对象(DSN、主机、账号密码)。纯粹的值对象(Request、Response、Domain 等)就地
new即可。 - 闭包工厂、懒加载 — 每个条目都是一个工厂闭包,首次被请求时才执行,结果会被缓存。
- 一个条目一个实例 — 每个条目在
MiRest对象生命周期内只解析出一个共享实例。 - 显式登记,不做自动装配 — 容器不做基于反射的自动装配、没有魔法解析;想用某个条目必须显式
set()。显式优于魔法:这让整个容器保持在 40 行以内,可一口气读懂。(本包有三处反射,都是「发现」而非「装配」:探测资源声明了哪些 HTTP 动词——供OPTIONS默认实现与405的Allow头使用——读取资源构造器第一个参数的类型,看它是否要容器,以及读取该构造器声明的参数名,看它是否要remaining。) - 无全局、无静态 — 容器挂在
MiRest实例上,随框架注入到每个资源。
全部 API 只有三个方法
$rest->set(string $id, callable $factory): self // 注册工厂(懒加载) $rest->has(string $id): bool // 是否已注册(工厂待执行或已解析) $rest->get(string $id): mixed // 解析实例(缓存复用)
$id 可以是类名(PDO::class),也可以是任意字符串别名('config'),因此接口、友好别名都能自然使用。
has() 与 get() 是 PSR-11 的;set() 是 MiRest 自己的注册面。
懒加载单例的流程:
$rest->set(PDO::class, fn() => new PDO('mysql:host=localhost;dbname=app', 'user', 'pass')); $rest->has(PDO::class); // true — 工厂已注册(尚未解析) $rest->get(PDO::class); // 第 1 次调用:执行工厂并缓存结果 $rest->get(PDO::class); // 第 2 次调用:返回同一个缓存实例 $rest->get('missing'); // 抛 NotFoundException —— 未注册的 id 属于装配错误
get() 会抛不是风格选择,而是 PSR-11 的要求:拼错或漏注册必须在要它的地方失败,
而不是过一阵子以「某个 null 不是对象」的形式冒出来。要探测可有可无的东西,用 has()。
NotFoundException 同时也是 RuntimeException;它不是 ResourceNotFoundException ——
后者是「没有资源匹配这个路径」(404),前者是「应用没装配对」。它在哪一层暴露取决于谁在问:
框架自己的 logger 在分发之前解析,配错就会从 handle() 抛出;而资源在分发过程中取的条目,
则会被兜成一个 500 响应。
重新 set() 会替换工厂并丢弃已缓存的实例,下次 get() 会构建全新对象 —— 适合重新部署或测试场景。
框架自身的 logger
框架报告内部错误时——资源或全局 after 钩子抛异常——会通过从容器解析的 LoggerInterface 落日志。这里没有内置默认值:它在每次 handle() 开始时解析,因此未注册(或类型不对)的条目会在第一个请求上大声失败,而不是被隐式兜底吞掉。想要静默,就显式注册 NullLogger:
$rest->set(\Psr\Log\LoggerInterface::class, fn() => new \Psr\Log\NullLogger());
在资源类内使用容器
资源把容器声明为构造器的第一个参数(类型写 ContainerInterface)即可获得它,之后直接取条目:
class users extends AbstractResource { public function __construct(private ContainerInterface $container) {} public function GET(Request $request): Response { $pdo = $this->container->get(PDO::class); // 容器中的共享 PDO $logger = $this->container->get(LoggerInterface::class); // 以及共享 logger $dao = new UserDao($pdo, $logger); // 普通 `new` —— 不需要容器 return Response::json($dao->getAll()); } }
容器是可选的:不声明就永远拿不到它,也不付任何代价。(这里的类名是 users 而非 Users——文件名就是类名。)
典型的启动装配:
$rest = new MiRest(baseDir: __DIR__ . '/resources', namespace: 'App\\Resources'); $rest->set(PDO::class, fn() => new PDO('mysql:host=localhost;dbname=app', 'user', 'pass')); $rest->set(\Psr\Log\LoggerInterface::class, fn() => new Monolog\Logger('app')); $rest->set(RedisCache::class, fn() => new RedisCache(new Redis()));
经验法则:需要配置 → 用 set() 注册。不需要配置 → 直接 new。无论哪种方式,你的业务代码都不依赖容器的任何魔法。
关于
namespace参数:每个资源文件声明的类名就是文件名(users.php→class users)。如果namespace为空,所有资源类都在全局作用域,一旦有两个以上资源就会类名冲突,报 "Cannot redeclare class" 致命错误。任何有多个资源文件的项目都应设置 namespace。
路由规则
资源文件以它所响应的路径命名:/users 是 users.php,/users/42 是
users/___user_id___.php。全程不做大小写转换——文件名就是路径,类名就是文件名。
对于请求路径 /foo/bar/baz,定位器逐级下降。每一级以 foo 为当前段:
foo.php— 当foo是最后一段时,由该文件响应foo/— 后面还有路径时,进入该目录,继续匹配bar/baz___name___.php— 通配文件,用于最后一段:任意段都匹配,并捕获为name___name___/— 通配目录,用于后面还有路径时__catch_remaining__.php— 响应当前段及其后的全部路径(两个下划线,不是三个——___name___是通配的形状)- 404 — 以上都不匹配,返回 404
资源构造器能收到三样东西,规则各不相同:
| 通道 | 怎么传 | 何时传 |
|---|---|---|
| 路径捕获 | 按名字 | 总是——路径上每个捕获到的值都要声明,漏掉的那个会在构建时报出名字 |
| 容器 | 位置第一 | 第一个参数类型为 ContainerInterface 时 |
| 剩余路径 | 按名字,remaining |
构造器声明了它时 |
users/___user_id___.php 声明 __construct(private string $user_id) {},读 $this->user_id;/users/{user_id}/posts 同样会拿到 user_id,因为到达的是整条路径的捕获,而不只是最后一段。捕获到的是原始段,因此参数只能声明为 string——校验或转换是资源自己的事。remaining 是保留名:若某个通配也捕获它,会被直接报错,而不是静默取舍。三样都不要的资源,连构造器都不用写。
同名的文件与目录可以并存:users.php 是集合本身,users/ 装它的下级。
剩余路径在匹配中断的那一层被接住。 __catch_remaining__.php 回应"没有任何文件或目录认领的那一段及其后的全部"——深度不定的资源就是这样写的:docs/__catch_remaining__.php 处理 /docs/a/b/c,构造器收到 $remaining = ['a', 'b', 'c']。
由此有两条边界,放这种文件之前都值得先知道:
- 目录是容器,进入它是为了继续往下走。精确目录即使在末段也会被进入——
/docs正是这样到达docs/里那个文件的。通配目录不会,所以/api/v1到不了api/___version___/里的捕获文件。___version___/是/api/v1/...的容器,不是/api/v1的资源;那一层需要一个文件(api/___version___.php)。 - 放在根目录就会接住全站没人认领的路径,
/nonexistent于是变成 200。放在你确实想接管的那一层。
最终,定位到的资源统一通过模板方法 handle()(before → HTTP 方法 → after)处理请求,因此无论是否有多余路径段,前置/后置钩子行为一致。
短路说明:资源级
before()返回Response时请求短路——HTTP 方法和资源级after()都不会执行。但注册在MiRest上的全局after钩子仍会执行(它们包裹整个分发过程)。
定位到资源之后,由 HTTP 动词决定执行什么:资源声明了就执行对应处理器,没声明则返回 405 Method Not Allowed——405 响应始终携带列出支持方法的 Allow 头(RFC 9110)。不是 HTTP 动词的请求同样得到 405,且不会调用任何处理器方法,因此资源自己的辅助方法不可能被当成处理器调用;但要注意资源本身仍会被实例化、其 before() 仍会先运行,之后才判定为 405。默认的 OPTIONS() 会通过 Allow 头报告该资源支持哪些方法。
匹配时会把 URL 段转小写、并在精确文件/目录查找时把 - 映射成 _,因此 /Blog-Posts、/blog-posts 和 /blog_posts 都会命中 blog_posts.php。这样 kebab-case 成为推荐的 URL 风格(PHP 标识符不能含 -,文件名自然是 snake_case)。这个映射由定位器的 nameFor() 方法定义,可通过子类覆写。通配捕获的值与 __catch_remaining__ 的剩余路径永远不做转换,因为它们是用户数据:/users/john-X-99 捕获 john-X-99(原始值),而不是 john_x_99。路径中的 . / .. 段会被丢弃,含反斜杠或 NUL 字节的段则直接拒绝路由,定位器永远不会逃出资源根目录;baseDir 必须是已存在的目录,否则抛出 InvalidArgumentException。
同级精确与通配的优先级:精确的 foo.php / foo/ 始终优先于通配。若同一深度两者都存在,精确匹配胜出——通配永远不会被命中。这是有意设计的:显式路由不应被参数捕获所遮蔽。
路由参数保留原始 URL 编码:从 ___param___ 文件或目录捕获的值按原样传入(例如 hello%20world 保持 hello%20world)。需要解码时对构造器参数用 urldecode() 即可。这是有意设计的——保留原始编码为 %2e%2e 这类路径穿越变体提供了额外的防护层。
文件名就是类名,所以 PHP 关键字不能做资源:
/new会需要class new {},解析器直接拒绝。改用复数(/news、/lists、/classes)或其他措辞——框架会把这类解析错误连同文件名和原因一起报出来。
resources/要放在文档根之外。 根资源就叫index.php,它同时也是 Web 服务器的默认文档名;若该目录位于 docroot 之下,直接请求它就可能被执行,从而绕过框架。
策略钩子
有两个钩子能在处理器运行之前拒掉请求,而它们能看到的东西不同:
MiRest::before() |
资源级 before() |
|
|---|---|---|
| 时机 | 定位器之前,每个请求都会经过 | 已定位的资源内,构造器参数已注入、动词尚未判定 |
| 手里有什么 | 只有 Request |
$request 与 static::class |
| 端点身份 | 没有——只有一个字面路径 | static::class 就是端点:一个端点一个键,不随参数取值变化 |
| 什么都没匹配上的路径 | 照样经过它 | 根本到不了 |
所以 MiRest::before() 适合与「命中哪个端点」无关的策略:维护开关、粗粒度的「除白名单外一律要求会话」、bot 拦截、统一安全响应头。按端点做的判定属于资源级 before(),因为两者中只有它知道自己是哪个端点——拿 $request->path 去写这种规则,等于把定位器已经解出的路由再推一遍,而前缀规则还会把该端点的子路径一并放行。
abstract class RestrictedResource extends AbstractResource { public function __construct(protected ContainerInterface $container) {} protected function before(Request $request): ?Response { // static::class 就是端点——...\users\___user_id___ 响应 /users/{id}, // ...\users 响应 /users。字面路径(端点级规则通常以它为键) // 在 $request 上。 $policy = $this->container->get('policy'); if (!$policy->allows(static::class, $request->method, $request->path)) { return Response::json(['error' => 'Forbidden'], 403); } return null; } }
继承这个基类、同时又要路径参数的资源得声明自己的构造器,并且必须把容器转发上去(parent::__construct($container)),否则 before() 会遇到未初始化的属性。
返回 Response 即短路,且该钩子在动词判定之前运行——因此拒绝不会告诉未授权者这个端点支持哪些方法。
基类读不到子类构造器的参数:那些值只在声明它们的资源内可见。所以端点级策略放在这里(基类、以端点为键);需要用到参数取值的规则属于行级,应留在资源自己内部。
一点提醒:基类是 fail-open 的。忘记继承的资源会静默失守,这正是要把粗粒度、看不见端点的默认策略留在 MiRest::before()、而把按端点的判定当作叠加层而非唯一防线的原因。
API 参考
MiRest
public function handle(Request $request): Response
主入口,将请求分发到对应的资源并返回响应。
$rest->before(callable $handler): self // 全局前置钩子 $rest->after(callable $handler): self // 全局后置钩子 $rest->notFound(callable $handler): self // 自定义 404 $rest->error(callable $handler): self // 自定义异常处理 $rest->set(string $id, callable $factory): self // 注册条目(懒加载) $rest->has(string $id): bool // 是否已注册 $rest->get(string $id): mixed // 解析实例(缓存)
Request
$request->method // HTTP 方法: GET, POST, PUT, DELETE... $request->path // 请求路径: /users/123 $request->query // 查询参数数组 $request->body // 解析后的 body 数组(JSON/表单) $request->headers // 请求头数组 $request->server // $_SERVER 数组 $request->files // 上传文件数组($_FILES) $request->header(string $name, mixed $default = null): mixed Request::fromGlobals(): self
Response
Response::json(mixed $data, int $status = 200): self Response::html(string $html, int $status = 200): self Response::redirect(string $url, int $status = 302): self Response::empty(int $status = 204): self $response->withHeader(string $name, string $value): self $response->withStatus(int $status): self $response->send(): void
AbstractResource
| 方法 | 说明 |
|---|---|
GET() / POST() / PUT() / DELETE() / PATCH() |
HTTP 方法处理器(子类重写)。未重写的方法一律返回 405 Method Not Allowed |
OPTIONS() |
返回 204 并带 Allow 头,列出该资源支持的方法(始终含 OPTIONS 与 HEAD,再加上子类声明的那几个 —— 通过反射探测,所以只要声明 POST() 就会出现) |
HEAD() |
与 GET() 相同但不返回响应体 |
before(Request): ?Response |
前置钩子,返回响应则短路 |
after(Request, Response): Response |
后置钩子,修改并返回响应 |
assertInt(mixed, string): int |
验证整数,失败抛 404 |
License
MIT