Platforms and APIs
My day-to-day work is building and maintaining the platforms behind apps and devices out in the field: the APIs they talk to, the administration systems that sit behind them and the infrastructure it all runs on. Most of it is in the Laravel ecosystem, hosted on AWS or DigitalOcean.
How I shape an API
I give each kind of caller its own way of signing in, with its own authentication guard: a device registers once and gets a signed token that expires, people sign in to the admin system with a normal session, and any other system we integrate with gets a key of its own for that one integration.
I like every endpoint to behave the same way: JSON in and out, a version in the path, lists that page, and one error shape, a 422 that tells you exactly which field is wrong and why. Anything a caller might reasonably retry is safe to send twice, batches are answered entry by entry with a 207 so one bad entry cannot lose the rest, and the same validation rules run whether a record arrives through the API or through an import.
Request
POST /api/v2/sessionsAuthorization: Bearer eyJhbGciOi…Content-Type: application/json{ "device_id": "dv_7f3a", "started_at": "2026-10-10T08:14:02Z", "duration_seconds": 1860 "duration_seconds": -4}
201 Created
{ "id": "ses_8k2m", "status": "stored"}
422 Unprocessable Content
{ "errors": { "duration_seconds": [ "must be at least 1" ] }}
What a platform is made of
The shape is much the same from one platform to the next: a web server on the edge of a private network, the Laravel application behind it, a database per tenant, a cache, a queue with workers picking jobs off it, object storage sat behind a CDN and an error monitor keeping an eye on the lot.
- Device Device
- Phone app Phone
- Browser (admin) Browser
- Queue (SQS) Queue
- Web server (nginx) nginx
- CDN (CloudFront) CDN
- Workers Workers
- Application (Laravel) Laravel
- Error monitor (Sentry) Sentry
- Cache Cache
- Databases, one per tenant Databases
- Object storage (S3) S3
- Private network (VPC) VPC
A device signs in
- The device calls the handshake endpoint with its registered identifier.
- The application looks the device up in the database.
- It signs a JSON Web Token and sends it back.
- Every later call from the device carries the token.
A session is uploaded
- When a session ends, the device posts its results, inputs and event logs. The events arrive as one batch.
- The application validates each event on its own. One of them fails.
- The events that passed are stored in the tenant’s database.
- A job goes on the queue, and a worker picks it up.
- The answer is
207 Multi-Status: a list of what was stored, and a list of what failed and why. One bad entry has not lost the rest.
A large file is uploaded
- In the admin system, the browser asks the application for somewhere to put a large file.
- The application answers with a presigned URL, which allows that one upload for a short time.
- The browser sends the file straight to object storage. It never passes through the web server or the application.
A request arrives for one tenant
- A request arrives at one tenant’s subdomain.
- The application reads the subdomain and works out which tenant it belongs to.
- It switches to that tenant’s own database, cache and file storage. The other tenants’ are left alone.
- The response goes back, built only from that tenant’s data.
The administration tools
Alongside every API I build an administration system for the people actually running the platform. It is the same Laravel application and the same database, behind a separate sign-in, with a custom interface built in Vue.js and Inertia rather than an off-the-shelf admin package.
What someone can see and do depends on their role, so an administrator for one organisation manages their own users, devices and content and nothing beyond that. This is where devices get registered (and switched off again when they need to be), where content is uploaded, large video files included, and where everything the devices send back ends up, from completed sessions and results through to the entries that failed validation and why.
It is also where data goes in and out by hand, whether that is importing from a spreadsheet or exporting for reporting, and those go through the same checks as the API does.
Devices
Content
Latest sessions
What it runs on
- Hosting
- AWS (EC2, RDS, S3, CloudFront, SQS, VPC) and DigitalOcean (Droplets, Spaces, VPC, managed databases)
- Servers
- Laravel Forge, nginx, Apache
- Queues and jobs
- Laravel Horizon, Redis, SQS
- Edge
- Cloudflare, Cloudflare Workers
- Delivery
- Git, GitHub Actions, Docker
- Monitoring
- Sentry, Laravel Pulse, Laravel Telescope
- Search
- Laravel Scout, Meilisearch
- Amazon SES, Postmark