Scalable URL shortener built with Laravel 8, designed for high read volume using Redis, async visit counters, and queue workers. Runs entirely in Docker — no local PHP or database setup required.
Client → Nginx → Laravel (PHP-FPM) [horizontally scalable]
├─ POST /api/shorten → MySQL + Redis + Queue
├─ GET /api/top → MySQL (cached 60s)
└─ GET /{code} → Redis string lookup + async visit counter
Queue worker → CrawlUrl (fetch page title)
Scheduler → flush pending visit counts from Redis to MySQL every minute
| Component | Role |
|---|---|
| Nginx | Reverse proxy (port 8080), FastCGI to scaled app tasks |
| App | Laravel API + redirects (scale with --scale app=N) |
| MySQL 8 | Persistent URL storage |
| Redis 7 | Redirect strings, visit counters, queue |
| Queue | Background title crawling |
| Scheduler | Flushes visit counters every minute |
- Lean middleware —
GET /{code}uses a dedicatedredirectgroup (no session, cookies, or CSRF). - Redis string —
url:redirect:{code}holds the long URL (30-day TTL); MySQL only on cache miss. - Async visits —
INCR+SADDin a Redis pipeline; scheduler flushes to MySQL.
See docs/load-testing.md for capacity findings and stress-test methodology.
git clone https://github.com/fellipesg/url-shortener.git
cd url-shortener
docker compose up -d --buildRecommended Docker Desktop resources: at least 2 CPU / 2 GB RAM; 4 CPU / 4 GB for meaningful load tests.
Scale PHP-FPM horizontally:
docker compose up -d --build --scale app=3
# or: make scale REPLICAS=3Configuration: .env.docker (copied to .env inside the container on first boot).
curl -s -X POST http://localhost:8080/api/shorten \
-H "Content-Type: application/json" \
-d '{"url":"https://en.wikipedia.org/wiki/Genghis_Khan"}'Rate limit: 30 requests/minute per IP on /api/shorten.
curl -I http://localhost:8080/a1b2c3d4Returns 302. Visits are counted asynchronously in Redis and flushed to MySQL every minute.
curl -s http://localhost:8080/api/topCached for 60 seconds. Global API throttle: 60 requests/minute per IP (by design).
make up # docker compose up -d --build
make test # phpunit inside container
make stress-seed # 50k URLs + Redis warm
make scale REPLICAS=3
docker compose logs -f app nginx
docker compose down -v # reset volumes| Area | Change |
|---|---|
| Routes | API on routes/api.php; redirect on routes/redirect.php with minimal middleware |
| Services | UrlShortenerService, ShortCodeGenerator, VisitCounter, UrlShortenerRepository |
| Redis | Raw string redirect cache; pipelined visit counters; /top cache invalidation on create |
| Docker | PHP 8.2-FPM, OPcache, tuned nginx, compose stack (mysql, redis, queue, scheduler) |
| FPM | Static pool pm.max_children=16 (sized for ~4 GB host; was 64) |
| Scale | docker compose --scale app=N; migrations guarded by MySQL GET_LOCK |
| Tests | Feature tests + StressTestSeeder + stress runners under tools/stress/ |
Load tests showed that methodology and host resources matter more than Laravel version for this workload.
- Firehose tests mislead — firing 300k requests with high concurrency and 10s curl timeouts produced <1% success on a 1 GB Docker VM, even after code tuning. That measured saturation, not steady-state capacity.
- Resources were the silent killer — with 4 CPU / 4 GB and fair sustained RPS tests, 1× app achieved 200 redirect RPS at 100% success (p50 ~28 ms) and a ceiling of ~240 RPS.
- More replicas ≠ faster on one VM —
--scale app=3on a single Docker Desktop machine hurt throughput at 200–400 RPS (three FPM pools competing for the same cores). - Easy wins that helped — lean redirect middleware, Redis string hot path, Redis pipeline for visits, FPM/nginx/OPcache tuning,
php-fpm -Ffor container stability. /api/topat 50 RPS — ~4% success is expected:throttle:api(60/min) returns 429, not a capacity bug.- Laravel upgrade — would help maintenance and PHP version, but would not replace edge redirect (OpenResty/Lua) or proper host sizing for millions of redirects/month.
Full tables, JSON artifacts, and Cursor canvas paths: docs/load-testing.md.
- Redis redirect cache on the hot path
- Deferred visit counters (Redis → batch MySQL flush)
- Redis queue for title crawling
- Cached
/api/top(60s) - Indexes: unique
short_url, index onvisits - Rate limits on create and API
Future (out of scope here): redirect at the edge (nginx/OpenResty + Redis), read replicas, sharding, multi-region.
app/
├── Console/Commands/StressLoadCommand.php
├── Http/Controllers/UrlShortenerController.php
├── Jobs/CrawlUrl.php
├── Repositories/UrlShortenerRepository.php
└── Services/
docker/
├── nginx/
└── php/
docs/
├── load-testing.md
└── canvas/ # Cursor canvas snapshots (open beside chat in Cursor IDE)
tools/stress/
storage/stress/ # committed JSON results from load runs
MIT