A RESTful API built with Symfony 7 that determines the lowest applicable price for a product by evaluating a set of active promotions. Each promotion type applies a different pricing strategy — the engine picks the one that results in the lowest total price for the customer.
Built as a portfolio project to demonstrate backend API design, design patterns, caching, rate limiting, and testing practices in PHP.
| Layer | Technology |
|---|---|
| Framework | Symfony 7 / PHP 8 |
| Database | PostgreSQL 18 |
| Search | Elasticsearch 9 |
| Messaging | Apache Kafka 4.2 |
| Cache / Rate Limiting | Redis |
| Infrastructure | Docker, Nginx, PHP-FPM |
| API Docs | OpenAPI 3.0 (NelmioApiDocBundle + Swagger UI) |
| Testing | PHPUnit 12 (unit + integration) |
Each promotion type (even_items_multiplier, date_range_multiplier, fixed_price_voucher) is implemented as a separate class behind a PriceModifierInterface. A PriceModifierFactory resolves the correct implementation at runtime from the promotion's type string — making it easy to add new promotion types without touching existing logic.
After deserializing the request body into a DTO, an AfterDtoCreatedEvent is dispatched. A DtoSubscriber listens to this event and runs Symfony's constraint validator — keeping validation decoupled from the controller.
Rate limiting is enforced at the kernel.request level via a RateLimitListener. Each controller action is annotated with a #[RateLimit(limit: N, intervalSeconds: M)] PHP attribute — the listener reads this via reflection and applies a per-IP, per-endpoint sliding window limiter backed by Redis. Limits can be tuned per endpoint by changing the attribute value; no listener or config changes needed. Endpoints without the attribute are not rate limited. Exceeding the limit returns 429 Too Many Requests.
Products and promotions are indexed in Elasticsearch for fast full-text search. The ElasticsearchService handles indexing, bulk operations (chunked for large datasets), and search queries with fuzzy matching. A console command (app:elasticsearch:reindex) streams records from PostgreSQL using Doctrine's toIterable() to keep memory usage constant regardless of dataset size.
Valid promotions for a product are cached in Redis for 1 hour (PromotionCache). Search results are also cached in Redis to avoid hitting Elasticsearch on repeated queries.
A dedicated PostgreSQL instance runs for tests (postgres_test service in Docker), keeping the test environment fully isolated from the development database.
src/
├── Attribute/ # Custom PHP attributes (RateLimit)
├── Cache/ # Redis caching layer
├── Controller/ # HTTP layer
├── DTO/ # Request/response data objects
├── Entity/ # Doctrine ORM entities (Product, Promotion, ProductPromotion)
├── Enum/ # Backed enums (PromotionType)
├── Event/ # Custom domain events
├── EventListener/ # kernel.request listeners (RateLimitListener, ExceptionListener)
├── Filter/ # Core pricing logic
│ └── Modifier/ # Strategy implementations per promotion type
└── Service/ # Serialization, exception handling
Evaluates all active promotions for the given product and returns the lowest achievable price.
Request:
{
"quantity": 3,
"request_date": "2024-06-01",
"voucher_code": "SUMMER10"
}Response:
{
"quantity": 3,
"voucher_code": "SUMMER10",
"request_date": "2024-06-01",
"price": 1000,
"discounted_price": 850,
"promotion_id": 2,
"promotion_name": "Summer Sale"
}Rate limit: 60 req / 60s per IP.
Returns all currently valid promotions for a product.
Response:
[
{
"id": 1,
"name": "Summer Sale",
"type": "date_range_multiplier",
"adjustment": 0.85,
"criteria": { "start": "2024-06-01", "end": "2024-08-31" }
}
]Rate limit: 120 req / 60s per IP.
| Method | Path | Description | Rate limit |
|---|---|---|---|
GET |
/promotions |
List all promotions | 120 req / 60s |
GET |
/promotions/{id} |
Get a promotion by ID | 120 req / 60s |
POST |
/promotions |
Create a new promotion | 30 req / 60s |
PUT |
/promotions/{id} |
Update a promotion | 30 req / 60s |
DELETE |
/promotions/{id} |
Delete a promotion | 10 req / 60s |
Promotion types: date_range_multiplier, fixed_price_voucher, even_items_multiplier
Create request example:
{
"name": "Black Friday",
"type": "date_range_multiplier",
"adjustment": 0.5,
"criteria": { "start": "2024-11-29", "end": "2024-11-29" }
}| Method | Path | Description | Rate limit |
|---|---|---|---|
GET |
/search/products?q=laptop |
Full-text search on products | 120 req / 60s |
GET |
/search/promotions?q=summer&type=date_range_multiplier |
Full-text search on promotions (optional type filter) | 120 req / 60s |
Tested under high data volume stress conditions. Load test: 1000 requests, 100 concurrent users.
| PostgreSQL LIKE | Elasticsearch | ES + Redis Cache | |
|---|---|---|---|
| Avg response time | 27998ms | 567ms | 375ms |
| Requests/sec | 3.57 | 176 | 266 |
| Total time (1000 req) | 280s | 5.7s | 3.7s |
PostgreSQL
LIKEdoes a full table scan on every request. Elasticsearch searches on pre-indexed data with fuzzy matching. Redis caches search results so repeated queries skip Elasticsearch entirely.
Interactive Swagger UI is available at:
http://localhost:8080/api/doc
Raw OpenAPI JSON spec:
http://localhost:8080/api/doc.json
Powered by NelmioApiDocBundle. All endpoints are annotated with OpenAPI attributes (#[OA\...]) directly in the controllers. The spec covers both the /products and /promotions route groups.
cp .env.example .env
docker-compose up -d
docker exec symfony_php php bin/console doctrine:migrations:migrate
docker exec -it symfony_kafka /opt/kafka/bin/kafka-topics.sh --create --topic promotions_events --bootstrap-server localhost:9092 --partitions 1 --replication-factor 1
docker exec -it symfony_php php bin/console app:elasticsearch:reindex
docker exec -it symfony_php php bin/console cache:clearStart Kafka consumer:
docker exec -it symfony_php php bin/console messenger:consume async -vvAPI available at http://localhost:8080.
Unit and integration tests are included. Integration tests run against the real test database.
docker exec symfony_php php bin/phpunitTwo Go services in separate repos are built to work with this engine:
- data-generator — Writes random product rows to a CSV file, with an option to gzip the output. Useful for loading a database with millions of rows.
- import-export-service — It imports/exports CSV/JSON files into/from the engine’s Postgres database in batches. It connects to the engine’s Docker network, so you don’t need to expose any database ports on the host
This project is licensed under the MIT License — see the LICENSE file for details.