Skip to content

Repository files navigation

Promotions Engine

License: MIT PHP Symfony PostgreSQL Redis Elasticsearch Kafka Docker OpenAPI PHPUnit CI

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.

Tech Stack

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)

Architecture & Design Decisions

Strategy + Factory Pattern for Price Modifiers

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.

Event-Driven DTO Validation

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

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.

Elasticsearch Full-Text Search

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.

Redis Caching

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.

Separate Test Database

A dedicated PostgreSQL instance runs for tests (postgres_test service in Docker), keeping the test environment fully isolated from the development database.

Project Structure

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

API

POST /products/{id}/lowest-price

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.


GET /products/{id}/promotions

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.


Promotions CRUD

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" }
}

Search

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

Search Performance Benchmark

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 LIKE does 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.


API Documentation

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.

Setup

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:clear

Start Kafka consumer:

docker exec -it symfony_php php bin/console messenger:consume async -vv

API available at http://localhost:8080.

Tests

Unit and integration tests are included. Integration tests run against the real test database.

docker exec symfony_php php bin/phpunit

Related projects

Two 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

License

This project is licensed under the MIT License — see the LICENSE file for details.

About

Symfony 7 REST API that calculates the lowest applicable price for a product by evaluating active promotions — strategy pattern, Redis caching, event-driven validation

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Contributors

Languages