A REST backend sample in PHP 8.5, Laminas and Doctrine, replicating a slice of the Path of Exile developer API. Paths, response shapes, error codes, auth and rate limiting follow the published docs. Data remains local, however, and GGG's API is not called.
It has been hand-wired from Laminas components, rather than go full-framework, so that every part can be explained. The commit history follows that progression.
- Endpoints:
/profileand/item-filter(read-many, read, create, partial update), with the documented response shapes and error codes - Auth: mocked OAuth 2.1 bearer tokens with per-route scopes, expiry, revocation and per-profile ownership (details)
- Rate limiting: per token in Redis, with the documented headers and 429 responses (details)
- API docs: hand-written OpenAPI 3.1 spec with Swagger UI; a contract test keeps it in step with the code (details)
- Persistence: Doctrine entities and embeddables, reviewed migrations, created/updated timestamps from an injected clock, fixtures seeded from real item filters
- Stack: Nginx, php-fpm, PostgreSQL, Redis and Swagger UI in Docker Compose
- Quality: PHPUnit unit and contract tests; Mago formatting, linting and strict static analysis, all enforced in CI
Replicates the resource server (api.pathofexile.com) only. Token issuance
(www.pathofexile.com/oauth) is out of scope: dev tokens are seeded as if the
authorization server had issued them, and stored as SHA-256 hashes. As in the
real API,
the token identifies its owner, so account:* endpoints need no id in the path.
| Endpoint | Scope |
|---|---|
GET /profile |
account:profile |
GET|POST /item-filter, GET|POST /item-filter/{id} |
account:item_filter |
| Dev token | Demonstrates |
|---|---|
dev-token-full |
full access, as ProfileOne |
dev-token-profile-only |
403 (code 6) on /item-filter: missing scope |
dev-token-expired |
401 (code 8): expired |
dev-token-revoked |
401 (code 8): revoked |
dev-token-other-profile |
ProfileTwo: ProfileOne's filters are 404 |
401 and 403 responses carry WWW-Authenticate
(RFC 6750). Another
profile's filter is a 404, not a 403, so its existence isn't revealed.
Follows the documented
rate-limit headers,
with one policy (api) and one rule (client): 10 requests per 5 s per token,
then restricted for 10 s. Every authorised response reports the state; over the
limit it's 429 Too Many Requests with Retry-After and error code 3.
X-Rate-Limit-Policy: api
X-Rate-Limit-Rules: client
X-Rate-Limit-Client: 10:5:10 # <max hits>:<period s>:<restricted s>
X-Rate-Limit-Client-State: 1:5:0 # <hits>:<period s>:<active restriction s>The limiter runs after authentication, so 401 and 403 carry no rate-limit headers. If Redis is down, requests fail closed with a 500 (code 4).
flowchart TD
C[Client] -->|request| N[Nginx :8000<br/>or composer serve :8080]
N --> I[public/index.php]
I --> R[Router<br/>route + method]
R --> Au[BearerAuthenticator<br/>token + scope]
Au --> RL[RateLimiter<br/>per token]
RL --> H[Handler]
H --> V[JsonBody + InputFilter<br/>parse + validate]
V --> Rp[Repository]
Rp --> A[Adapter<br/>entity → documented shape]
A --> J[JsonResponseFactory]
J -->|response| C
Au -.->|token lookup| D[(PostgreSQL)]
Rp -.->|read / write| D
RL -.->|count hits| RD[(Redis)]
R -. "404 no route · 405 method" .-> J
Au -. "401 token · 403 scope" .-> J
RL -. "429 over limit" .-> J
V -. "400 JSON / content type · 422 fields" .-> J
Rp -. "404 not found or not owned" .-> J
Dotted exits are the request-level errors. Server-side failures (500, 501) and every response per endpoint are in the API docs.
Requires Docker and Composer, plus PHP 8.5 with pdo_pgsql locally for the
Composer scripts, tests and composer serve.
git clone https://github.com/blakesimpson-dev/laminas-api-sample.git
cd laminas-api-sample
composer install
cp .env.example .env # local dev defaults
composer reset # fresh Postgres + migrations
composer fixtures:load # seed data and dev tokens
composer up # Postgres, Redis, php-fpm, Nginx, Swagger UI
export AUTH='Authorization: Bearer dev-token-full'
curl -H "$AUTH" localhost:8000/profile
curl -H "$AUTH" localhost:8000/item-filter
curl -H "$AUTH" localhost:8000/item-filter/<id>- Nginx (:8000) serves
public/, passes onlyindex.phpto php-fpm (any other.phppath is a 404), gzips JSON and hides server and PHP versions. composer serve(:8080) is the quick local alternative; both can run against the same database.
docs/openapi.json is the contract, written by hand 😭 Open Swagger UI at
http://localhost:8000/docs, click Authorize, paste a dev token, then Try
it out. It's same-origin with the API, so there's no CORS.
- Spec first: a contract test fails if an adapter adds or drops a key the spec doesn't match, in either direction.
- Scopes: OpenAPI can't attach scopes to bearer auth, so each operation
states its scope in its description and
x-required-scope. - Lint:
docker run --rm -v "$PWD/docs:/spec" redocly/cli lint /spec/openapi.json
| Script | Purpose |
|---|---|
composer up / down |
Start (building images) / stop the Docker stack |
composer serve |
PHP built-in server on :8080 |
composer reset |
Recreate Postgres and run migrations |
composer fixtures:load |
Purge and reseed |
composer migrations:diff / :migrate |
Generate / apply Doctrine migrations |
composer test |
PHPUnit unit and contract tests |
composer lint / analyze |
Mago linter / static analyzer (fail on warnings) |
composer format / format:check |
Mago formatter (PER-CS based) |
| Path | Contents |
|---|---|
config |
Routes, service manager, Doctrine |
src/Domains |
Per domain: entity, repository, adapter, handlers, validation |
src/Http |
Router, auth, rate limiting, JSON parsing, responses |
src/Infrastructure |
Doctrine, clock, Redis store, migration tooling |
docker |
php-fpm image, Nginx site config |
docs |
OpenAPI spec, README demo |
migrations |
Reviewed Doctrine migrations |
test |
Unit and contract tests, fixtures |
Item filter fixtures are NeverSink's filters (MIT), exported from FilterBlade. The application code is my own (MIT, see LICENSE); an LLM was used for explanations and tooling configuration. Not affiliated with or endorsed by Grinding Gear Games.
- Path of Exile developer docs: API reference, authorization, errors and rate limits, item filter format
- Laminas, Doctrine ORM, Migrations, Data Fixtures
- OpenAPI 3.1, Swagger UI, Redocly CLI
- Mago
