Bootstrapping
A Framework app has a single entry point. Your index.php loads Composer and hands the
request to Framework::execute(), which authenticates it, dispatches it to one of
your routes and prints the JSON response.
The entry point
Every request goes through one index.php that autoloads and calls execute(). That is
the whole file — there is no framework to boot, no container to configure:
// backend/index.php
<?php
require __DIR__ . "/../vendor/autoload.php";
use Framework\Framework;
Framework::execute();
Everything else — the config, the routes, the schemas — is discovered and already generated, so the entry point never grows as the app does.
The request lifecycle
A call to execute() runs the same seven steps every time:
| # | Step |
|---|---|
| 1 | Register the error handlers, so anything uncaught from here on is logged. |
| 2 | Read the request, and take the reserved parameters out of it. |
| 3 | Reject it when no route was sent. |
| 4 | Authenticate — an API token, or a credential from its tokens. |
| 5 | Merge the JSON body into the request, for an API call. |
| 6 | Resolve the route, check the access level and call your method. |
| 7 | Attach fresh tokens and print the JSON. |
Your code only ever sees step 6 — a Request arriving at a method that returns a Response.
Routing the url to the entry point
The client does not post a route name — it calls a url, and the web server turns the path into the
route parameter. With Apache that is one rewrite rule in the .htaccess at the root:
# .htaccess
RewriteEngine On
Options +FollowSymlinks
# Send everything under /api to the Framework
RewriteRule ^api(.+)$ backend/index.php?route=$1 [QSA,L]
So a call to /api/products/edit arrives as
backend/index.php?route=/products/edit, and the client keeps a REST-looking url while the framework
receives the route it dispatches on. QSA keeps any query string the caller sent, and L
stops the rewriting there.
The same trick gives a second entry point its own prefix — a webhook file that needs to run before any authentication, for instance:
RewriteRule ^m/(.*)$ backend/hook.php?type=messaging&route=$1 [QSA,L]
And the catch-all that follows sends everything else to the front end, so the API and the client app live under one domain:
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule . /index.html [L]
location /api block that passes
route=$1 to the same file. What matters is that route reaches PHP — the framework does
not parse the url itself.The reserved parameters
Every request carries a few parameters the framework consumes itself. They are removed from the request before your method runs, so they can never collide with a field of your own:
| Parameter | Is |
|---|---|
route | Which route to call — set by the rewrite, not by the client. Without it the request is rejected. |
token | The API token, for server-to-server calls. |
xAccessToken / xRefreshToken | The signed-in user's tokens. |
xLangcode | The language to answer in. |
xTimezone | The user's time zone. |
So a call is a url plus your own fields, and the client only has to keep the tokens it was last given —
the route is added by the rewrite:
POST /api/products/edit
{
"xAccessToken": "…",
"xRefreshToken": "…",
"productID": 7,
"name": "A new name"
}
Authentication
Two ways in, and the request takes whichever it was given. Neither is required — a request with no tokens runs as a signed-out visitor, and only reaches the routes that allow it.
A signed-in user
A browser client sends the tokens it was last handed, as ordinary fields. The language and time zone travel with them, so the answer comes back in the user's language:
POST /api/products/edit
{
"xAccessToken": "…",
"xRefreshToken": "…",
"xLangcode": "es",
"productID": 7
}
From there Auth knows who is calling, and fresh tokens
come back with the response.
A server calling the API
Another server has no session, so it authenticates with the static API token — the value of
AUTH_API_TOKEN in your environment. Set it to a long random
string and give it to the caller:
# .env
AUTH_API_TOKEN = "a-long-random-string"
The caller sends that value back, either as an Authorization: Bearer header or as a
token field. The header is the usual choice, since it keeps the credential out of the body and out of
any logged url:
curl -X POST https://myapp.com/api/products/getAll \
-H "Authorization: Bearer a-long-random-string" \
-H "Content-Type: application/json" \
-d '{"categoryID": 3}'
The Bearer prefix is stripped for you, so the token is what remains. The same call with the token
as a field instead:
curl -X POST https://myapp.com/api/products/getAll \
-H "Content-Type: application/json" \
-d '{"token": "a-long-random-string", "categoryID": 3}'
Either way the request is marked as an API call, which changes two things: it may send its body as
JSON — execute() merges the payload into the request, so your method reads it the
same as form fields — and it is answered with an error rather than a
logout when something is missing, since there is no session to end.
The token authenticates the caller, not a person: it is granted the API
access level, so it reaches the routes declared for it:
#[Route("/products/getAll", Access::API)]
public static function getAll(Request $request): Response { /* … */ }
CGIPassAuth On, or a rewrite that copies it into
HTTP_AUTHORIZATION — which the framework also reads as a fallback.Access & the responses it returns
Before your method runs, the route's required access level is checked against the caller's. Each failure has its own answer, so the client can tell them apart:
| When | The response is |
|---|---|
| The route does not exist | A general error — the same as an unauthorized one, so probing tells you nothing. |
| It needs a session and there is none | A logout response, telling the client to sign out — or an auth error for an API call. |
| The caller's level is too low | The same general error. |
| Your method threw | HTTP 400 with the message, and the exception in the error log. |
When the call succeeds and it was not an API request, fresh access and refresh tokens are attached to the response, so a signed-in session renews itself as the user works.
Internal requests
For trusted server-to-server traffic — a worker, a cron on another host — executeInternal() skips
the routing entirely. It validates the internal credentials and hands back the raw payload as a
Dictionary:
// public/internal.php
<?php
require __DIR__ . "/../vendor/autoload.php";
use Framework\Framework;
$payload = Framework::executeInternal();
$action = $payload->getString("action");
$id = $payload->getInt("id");
There is no route and no access level here — the caller is trusted by its credentials, and what happens next is yours to decide.
Outside the web
A console command or a cron does not go through execute() at
all. It runs the framework binary, which discovers the commands and calls one directly — so there is
no request, no route and no signed-in user. That is why anything running there has to be told the
language explicitly, and why the
queues are flushed by commands rather than by a request.