Framework
Introduction

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
1Register the error handlers, so anything uncaught from here on is logged.
2Read the request, and take the reserved parameters out of it.
3Reject it when no route was sent.
4Authenticate — an API token, or a credential from its tokens.
5Merge the JSON body into the request, for an API call.
6Resolve the route, check the access level and call your method.
7Attach 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]
On nginx the same idea is a 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:

ParameterIs
routeWhich route to call — set by the rewrite, not by the client. Without it the request is rejected.
tokenThe API token, for server-to-server calls.
xAccessToken / xRefreshTokenThe signed-in user's tokens.
xLangcodeThe language to answer in.
xTimezoneThe 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 JSONexecute() 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 { /* … */ }
If the header never arrives, the web server may be stripping it. Apache with CGI/FastCGI often needs 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:

WhenThe response is
The route does not existA general error — the same as an unauthorized one, so probing tells you nothing.
It needs a session and there is noneA logout response, telling the client to sign out — or an auth error for an API call.
The caller's level is too lowThe same general error.
Your method threwHTTP 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.