Writing Route Files
A route file is a plain PHP file in your project’s api/ directory. Deployed with lps api push, each file becomes one REST route on the site. This page is the complete reference for what a route file can do.
Anatomy of a route file
Section titled “Anatomy of a route file”<?php
declare(strict_types=1);
class HelloWorld{ public function get(): array { return ['message' => 'Hello, world!']; }}Saved as api/hello-world.php and pushed, this file answers at:
https://example.com/wp-json/loopress-api/v1/hello-worldTwo things tie everything together:
| Element | Rule | Example |
|---|---|---|
| Filename | Lowercase kebab-case path segments (letters, digits, hyphens), optionally nested in subdirectories, .php extension |
hello-world.php |
| Route | The path without extension, under the namespace | /loopress-api/v1/hello-world |
The class can be named anything. There’s no filename-to-class-name formula to get right: the plugin reads the file to find out what class it declares (via PHP’s own tokenizer, never by executing the file), so HelloWorld, Handler, or anything else all work identically. The one rule that matters: exactly one class per file. Zero classes, or more than one, is rejected.
Several structural requirements are enforced at push time, with a clear error if any fails:
- The file must contain
declare(strict_types=1);exactly once. More than once (even inside a comment) or zero times is rejected. - The file must declare exactly one class.
lps api pushrejects zero or several immediately, before anything is written; put code shared between several route files inlib/instead of a second class in the same file. - The class name must not already be taken by another
api/file, WordPress core, or another active plugin.lps api pushchecks all three and rejects the push immediately if any collide. - Every path segment must match the kebab-case pattern, or be a dynamic segment (see below). The CLI checks this before uploading, so a bad filename fails with an explicit message instead of a network error.
- Each file must be at most 512 KB. A single-class route file is never legitimately near that; the cap keeps an oversized file from exhausting memory when the plugin tokenises and loads it. Raise it with the
loopress_max_file_bytesfilter (add_filter('loopress_max_file_bytes', fn($bytes, $subdir) => $subdir === 'api' ? 1024 * 1024 : $bytes, 10, 2)) if you genuinely need to.
Dynamic path segments
Section titled “Dynamic path segments”A segment wrapped in brackets, [order_id], matches anything in that position and passes it through as a request param. The name inside the brackets follows PHP identifier rules (must start with a letter or underscore, since it becomes a named capture group internally), the same charset $request->get_param() needs anyway:
<?php
declare(strict_types=1);
// api/invoice-pdf/[order_id].php
class InvoicePdf{ public function get(WP_REST_Request $request): array { return ['order_id' => $request->get_param('order_id')]; }}Answers at /loopress-api/v1/invoice-pdf/482, /loopress-api/v1/invoice-pdf/anything-else, and so on, order_id available through $request->get_param('order_id') exactly like a query param. A route can have more than one dynamic segment, nested at any depth: api/orders/[order_id]/items/[item_id].php gives both order_id and item_id, class named however you like, same as any other route file.
There’s no catch-all segment (no [...path]): every segment, dynamic or not, is explicit and named. A route always has a fixed, predictable number of segments.
HTTP verbs
Section titled “HTTP verbs”The class exposes one public method per HTTP verb it handles:
| Method | HTTP verb |
|---|---|
get() |
GET |
post() |
POST |
put() |
PUT |
patch() |
PATCH |
delete() |
DELETE |
Only the verbs implemented as public methods are registered. A private or protected method is ignored, and every unimplemented verb is left off the route entirely, so WordPress answers it with its standard “no route” error.
One file can serve several verbs:
<?php
declare(strict_types=1);
class Item{ public function get(): array { return ['items' => get_option('my_items', [])]; }
public function post(WP_REST_Request $request): array { $items = get_option('my_items', []); $items[] = sanitize_text_field($request->get_param('name')); update_option('my_items', $items);
return ['items' => $items]; }}Handling the request
Section titled “Handling the request”Each verb method receives the standard WP_REST_Request object as its first argument. Declare the parameter if you need it, omit it if you don’t, both signatures work:
public function get(): array // no request data neededpublic function post(WP_REST_Request $request): array // reads params or bodyEverything WP_REST_Request offers is available:
public function post(WP_REST_Request $request): array{ $id = $request->get_param('id'); // query string or body, merged $body = $request->get_json_params(); // decoded JSON body $header = $request->get_header('x-signature');
return ['received' => $id];}Responses
Section titled “Responses”Return values go through WordPress’s standard REST serialization, so all the usual shapes work:
| Return | Result |
|---|---|
array |
JSON-encoded, status 200 |
WP_REST_Response |
Full control over status code and headers |
WP_Error |
JSON error body, with the status from the error data |
public function post(WP_REST_Request $request): WP_REST_Response|WP_Error{ $name = $request->get_param('name');
if (!is_string($name) || $name === '') { return new WP_Error('missing_name', 'The name parameter is required.', ['status' => 400]); }
return new WP_REST_Response(['created' => $name], 201);}Streaming a file instead of JSON
Section titled “Streaming a file instead of JSON”The three shapes above all go through WordPress’s standard REST serialization. For binary output, that’s usually fine as base64 inside the JSON body, see the cookbook for image, QR code, and spreadsheet examples that do exactly that, useful when the consumer is your own frontend turning the response straight into a data: URI.
When the consumer is a browser navigating to the URL directly, or a tool like curl -o, a real download needs real headers and raw bytes, not JSON. Nothing about a route file forces a return value through WP_REST_Response: a verb method can send its own headers, echo raw bytes, and exit before WordPress gets a chance to serialize anything.
public function get(WP_REST_Request $request): void{ $postId = (int) $request->get_param('post_id'); // ... build $pdfBytes ...
header('Content-Type: application/pdf'); header('Content-Disposition: attachment; filename="' . sanitize_title($filename) . '.pdf"'); echo $pdfBytes; exit;}See Rendering a WordPress Post as a Downloadable PDF for a full working example, permission(), wp_die() on a missing post, and all.
Authentication and permissions
Section titled “Authentication and permissions”By default, every route is closed: it requires an authenticated user with the manage_options capability, the same check the Loopress management endpoints use. Two ways to satisfy it:
- An application password for an administrator account, via HTTP Basic auth (what the CLI itself uses)
- A logged-in admin session with a REST nonce (requests from the WordPress admin)
To change who can call a route, add a public permission(WP_REST_Request $request): bool method. It replaces the default check for every verb in the file, and is called directly by WordPress when the route is dispatched, so it can inspect headers, params, or anything else on the request:
// Public route, no authenticationpublic function permission(WP_REST_Request $request): bool{ return true;}// Any logged-in userpublic function permission(WP_REST_Request $request): bool{ return is_user_logged_in();}// A shared secret in a header, e.g. for a webhookpublic function permission(WP_REST_Request $request): bool{ return hash_equals((string) get_option('my_webhook_secret'), (string) $request->get_header('x-webhook-secret'));}Returning false produces WordPress’s standard rest_forbidden response.
Defensive behavior, so a mistake never breaks the site: if permission() throws, the request it was checking is denied (fails closed, same as returning false) and the error is logged. The route itself stays registered and keeps working for every other request, only the request that hit the throw is affected.
Different permissions per verb, with #[Permission]
Section titled “Different permissions per verb, with #[Permission]”permission() applies to every verb in the file. When a file needs a different check per verb, for instance a public get() next to an admin-only post(), use the #[Permission] attribute instead, on a verb method or on the class:
use Loopress\Api\Attribute\Permission;
class Item{ #[Permission(public: true)] public function get(): array { return ['items' => get_option('my_items', [])]; }
#[Permission(capability: 'edit_posts')] public function post(WP_REST_Request $request): array { // ... }}A #[Permission] on the class applies to every verb that doesn’t have its own, a #[Permission] on a verb method overrides it for that verb only. Resolution order, most specific first: attribute on the verb, attribute on the class, the file’s permission() method, the closed manage_options default.
callback points to the actual check instead of a fixed capability, either a local method name or a shared static method for logic reused across several route files:
#[Permission(callback: 'checkSignature')]public function post(WP_REST_Request $request): array { /* ... */ }
public function checkSignature(WP_REST_Request $request): bool{ return hash_equals((string) get_option('my_webhook_secret'), (string) $request->get_header('x-webhook-secret'));}#[Permission(callback: [SharedChecks::class, 'requireApiKey'])] // SharedChecks::requireApiKey must be staticclass Webhook { /* ... */ }Same fail-closed behavior as permission(): a throwing callback denies the request and logs the error instead of breaking the site.
The blast radius of a public route
Section titled “The blast radius of a public route”#[Permission(public: true)] (or a permission() that returns true) makes the route’s code run for anyone on the internet, with no authentication. Whatever the verb method does, reading options, making outbound requests, touching the database, is then an unauthenticated capability. Treat a public route like a public-facing endpoint you wrote from scratch: validate every input, and never assume the caller is trusted.
Because a single attribute flips this, Loopress surfaces it:
lps api pushprints a warning for each route it just pushed that is public.lps api listbadges public routes[PUBLIC](andlps api list --jsonincludes"public": true).- The plugin’s API Routes admin tab shows a red Public badge on the row.
Detection is lexical (it reads the file’s tokens, it never runs the file), so it has two blind spots it does not warn about: an aliased import of the attribute (use Loopress\Api\Attribute\Permission as P; #[P(public: true)]), and a permission() method whose body returns true. Both are more deliberate than adding one attribute; write the attribute in its plain form if you want the warning.
Response headers and CORS
Section titled “Response headers and CORS”Two ways to set headers, depending on whether they vary per verb or apply to the whole route.
Per-verb headers
Section titled “Per-verb headers”Return a WP_REST_Response and call header() on it. This is the right place for anything that depends on the specific request, a cache directive that varies with the resource, a content-disposition on a download, and so on:
public function get(WP_REST_Request $request): WP_REST_Response{ $response = new WP_REST_Response(['order_id' => $request->get_param('order_id')]); $response->header('Cache-Control', 'private, max-age=60');
return $response;}Route-wide headers, including the OPTIONS preflight
Section titled “Route-wide headers, including the OPTIONS preflight”Add a public headers() method returning a map of header name to value. The headers are sent on every request to the route, including the OPTIONS preflight that WordPress answers automatically without ever calling your verb methods, which is exactly what browser CORS needs, and exactly what a per-verb WP_REST_Response can’t reach:
public function headers(): array{ return [ 'Access-Control-Allow-Origin' => 'https://app.example.com', 'Access-Control-Allow-Methods' => 'GET, POST, OPTIONS', 'Access-Control-Allow-Headers' => 'Content-Type, Authorization', ];}Only string-to-string pairs are applied, anything else in the array is ignored. If headers() throws, the error is logged and the request is served without the custom headers, it never breaks the response itself.
Don’t set the same header name in both places
Section titled “Don’t set the same header name in both places”headers() is applied after your verb method runs, so if the same header name appears in both a WP_REST_Response and headers(), headers() silently wins, even if the WP_REST_Response value was the more specific one. Keep the two non-overlapping: WP_REST_Response for whatever varies by verb or request, headers() strictly for what must apply route-wide, CORS being the main case.
The route namespace
Section titled “The route namespace”Routes register under the loopress-api/v1 namespace by default:
/wp-json/loopress-api/v1/{filename}The namespace is configurable from the plugin’s Settings tab (see Admin UI). The rules:
- Format: lowercase letters, digits, and hyphens, followed by a version segment, e.g.
acme/v1ormy-agency/v2. Anything else is rejected. loopress/v1is reserved for Loopress’s own management endpoints and cannot be chosen.- Changing it changes every route’s URL on the next request, with no redirect from the old URL. Update your consumers first.
Using your own Composer dependencies
Section titled “Using your own Composer dependencies”If the site also uses Composer to manage site-wide PHP dependencies, those packages are available to use in your route files directly, no manual require needed (unlike in code snippets, where you still load the autoloader yourself):
<?php
declare(strict_types=1);
use GuzzleHttp\Client;
class Webhook{ public function post(): array { $client = new Client(); // ... return ['ok' => true]; }}A corrupted or missing wp-content/loopress/vendor/autoload.php is caught and logged once, before any route file loads, the rest of the routes still register normally. A single package missing from an otherwise intact vendor/ isn’t caught by Loopress: it surfaces as an ordinary PHP error on that request, the same as calling any undefined class anywhere else in PHP. WordPress serves each request in its own process, so the error stays confined to the one route that hit it, but unlike the failures in Failure isolation below, it produces no Loopress api/: log line and no entry in the API Routes admin tab.
Sharing code between route files (and snippets)
Section titled “Sharing code between route files (and snippets)”wp-content/loopress/lib/ is for code reused across several route files, permission checks, formatters, anything that isn’t a route itself. Classes there are autoloaded under the LoopressLib\ namespace, the same use-and-go behavior as a Composer dependency:
<?php
declare(strict_types=1);
namespace LoopressLib;
use WP_REST_Request;
final class SharedChecks{ public static function requireApiKey(WP_REST_Request $request): bool { return hash_equals((string) get_option('my_api_key'), (string) $request->get_param('api_key')); }}use Loopress\Api\Attribute\Permission;use LoopressLib\SharedChecks;
#[Permission(callback: [SharedChecks::class, 'requireApiKey'])]class Webhook { /* ... */ }lib/ is never scanned for routes, it’s a plain autoload target, not another api/. In a code snippet, where the autoloader isn’t loaded automatically, require_once the Composer autoloader first, the same one step already needed there for any Composer dependency.
Failure isolation
Section titled “Failure isolation”A single bad route file can never take down the site or the rest of its REST API. Each file is loaded independently, and any of these problems skip that file only, with a line in the PHP error log prefixed Loopress api/: explaining why:
- A parse error or fatal error while loading the file
- A file that doesn’t declare exactly one class (zero, or more than one)
- A class name that is already taken by WordPress core, another plugin, or another route file
- A file over the 512 KB size limit, or a whole
api/directory over 8 MB total (a last-resort guard against a runaway directory; both are adjustable with theloopress_max_file_bytesandloopress_max_files_total_bytesfilters). A file skipped this way is not returned bylps api pull.
Every other route file keeps working, and so does everything else on the site. A throwing permission() is a different, narrower failure mode, not a load failure: the file still loads and the route stays registered, only the individual request that hit the throw is denied, see Authentication and permissions.
A skipped file also shows up as a warning in the plugin’s API Routes admin tab (see Admin UI), with the same reason as the error log, so you don’t have to go looking for it there. The warning clears itself the next time the file loads cleanly, nothing to dismiss manually.
Most of these never make it that far: lps api push rejects the same problems immediately, before anything is written, so they surface as a CLI error at push time instead of a log line discovered later:
- Invalid PHP syntax: a server-side
php -lcheck on each file. On hosts where it can’t run (no PHP CLI binary,execdisabled), the push goes through and a broken file is caught by the isolation above instead. - Zero or more than one class declared.
- A class name collision, checked against WordPress core, every other active plugin, and every other
api/file already on the site.
Where files live on the server
Section titled “Where files live on the server”Pushed files are stored in wp-content/loopress/api/, one {filename}.php per route. Details that matter operationally:
- Direct access is blocked.
wp-content/is publicly reachable over HTTP, so on push the plugin injects a standardABSPATHguard right after thedeclareline. A direct browser request to the file exits immediately; the code only runs through the REST API. The guard is stripped again when the CLI pulls or lists files, so your local copies stay exactly as you wrote them. - Directory listing is blocked by an empty
index.php. - Writes are atomic. Files are written to a temp file and renamed, so a REST request arriving mid-push never loads a half-written file.
- Removal goes through the product. Because these files sit outside the plugin directory, deactivating the plugin does not delete them. Use
lps api rm <slug>to remove one, orlps api push --pruneto remove every route absent locally. Uninstalling the plugin (deleting it, not just deactivating) removes the wholewp-content/loopress/tree, route files included.
You never need to touch this directory: the CLI is the only intended writer, and lps api pull reconstructs your local directory from it at any time.