A REST API for URL shortening built in PHP 8.2, demonstrating clean architecture with a strict separation between Domain, Application, Infrastructure, and HTTP layers. No full framework — just Slim-style routing, PSR-4 autoloading, and PDO.
┌─────────────────────────────────┐
│ HTTP Layer │ ← Routes, handlers, JSON responses
│ Http/Handler + Http/Response │
└────────────────┬────────────────┘
│
┌────────────────▼────────────────┐
│ Application Layer │ ← Use cases + DTOs
│ UseCase/ + DTO/ │
└────────────────┬────────────────┘
│
┌────────────────▼────────────────┐
│ Domain Layer │ ← Entities, interfaces, exceptions
│ Model/ Repository/ Exception/│
└────────────────┬────────────────┘
│
┌────────────────▼────────────────┐
│ Infrastructure Layer │ ← PostgreSQL adapter, DB connection
│ Persistence/ Database/ │
└─────────────────────────────────┘
src/
├── Domain/
│ ├── Model/ShortUrl.php # Core entity (immutable, readonly)
│ ├── Repository/ShortUrlRepositoryInterface.php
│ └── Exception/ # Domain-specific exceptions
├── Application/
│ ├── UseCase/ # One class per use case
│ │ ├── CreateShortUrl.php
│ │ ├── ResolveShortUrl.php
│ │ ├── ListShortUrls.php
│ │ └── DeleteShortUrl.php
│ └── DTO/ # Typed input/output objects
├── Infrastructure/
│ ├── Database/Connection.php # PDO connection manager
│ └── Persistence/PostgresShortUrlRepository.php
└── Http/
├── Handler/ShortUrlHandler.php # Request → UseCase → Response
└── Response/JsonResponse.php # Consistent JSON envelope
| Method | Endpoint | Description |
|---|---|---|
POST |
/urls |
Create a new short URL |
GET |
/urls |
List all short URLs |
GET |
/urls/{slug} |
Resolve a slug (returns JSON) |
DELETE |
/urls/{id} |
Delete a short URL by ID |
GET |
/{slug} |
Browser redirect to the original URL |
Requirements: PHP 8.2+, Composer, PostgreSQL
# Clone and install dependencies
git clone https://github.com/YOUR_USERNAME/url-shortener-api.git
cd url-shortener-api
composer install
# Configure environment
cp .env.example .env
# Edit .env with your PostgreSQL credentials
# Run the database migration
psql -U postgres -d url_shortener -f config/migrations.sql
# Start the development server
php -S localhost:8000 -t publicCreate a short URL (auto-generated slug):
curl -X POST http://localhost:8000/urls \
-H "Content-Type: application/json" \
-d '{"url": "https://www.example.com/very/long/path"}'{
"success": true,
"data": {
"id": "a1b2c3d4-...",
"original_url": "https://www.example.com/very/long/path",
"slug": "xK3mP2q",
"short_url": "http://localhost:8000/xK3mP2q",
"click_count": 0,
"created_at": "2024-11-01T10:00:00+00:00"
}
}Create with a custom slug:
curl -X POST http://localhost:8000/urls \
-H "Content-Type: application/json" \
-d '{"url": "https://www.example.com", "slug": "my-link"}'List all URLs:
curl http://localhost:8000/urlsResolve a slug (API):
curl http://localhost:8000/urls/my-linkBrowser redirect — just visit http://localhost:8000/my-link in a browser.
Delete a URL:
curl -X DELETE http://localhost:8000/urls/a1b2c3d4-...composer install
./vendor/bin/phpunitUnit tests use an in-memory fake repository — no database connection needed. This is only possible because the application layer depends on ShortUrlRepositoryInterface, not the concrete PostgreSQL class.
tests/
├── Unit/
│ ├── ShortUrlTest.php # Domain model invariants (7 tests)
│ ├── CreateShortUrlTest.php # CreateShortUrl use case (5 tests)
│ └── ResolveAndDeleteTest.php # Resolve + Delete use cases (5 tests)
└── Integration/ # (requires live DB — add as needed)
ShortUrl uses PHP 8.2 readonly properties. Once constructed, no code can accidentally mutate the entity's fields — the only way to "change" an entity is to create a new one (see withIncrementedClickCount()). This eliminates a whole class of state-related bugs.
create() enforces all business invariants (valid URL, valid slug). reconstitute() bypasses them — data loaded from the database was already valid when it was saved. Mixing the two would mean either running redundant validation on every DB read, or weakening validation on new entities.
ShortUrlResponse decouples the domain model from the API contract. If ShortUrl gains a new internal field, the API response shape stays unchanged until you explicitly add it to the DTO. It also makes it obvious what data crosses each layer boundary.
The project deliberately avoids pulling in a full framework to show that clean architecture is a design choice, not a framework feature. The routing in public/index.php is 30 lines. Adding Slim or Laravel later requires only wiring the handlers differently — no application code changes.
URLs are stored as TEXT (not VARCHAR(255)) because modern URLs — especially with query strings — easily exceed 255 characters. PostgreSQL's TEXT type has no meaningful performance difference from VARCHAR without a length constraint.
MIT