E-Commerce API for AI Agent Integration
How we built a Symfony REST API that bridges an AI assistant and a commodity e-commerce platform, featuring bearer token authentication, automated backward compatibility checking, and custom serialisation for legacy systems.
The Challenge
A commodity e-commerce platform needed to integrate with an external AI assistant to enable conversational commerce. The AI required programmatic access to catalogue data, pricing tiers, customer information, portfolio holdings, and order creation capabilities.
The existing platform consisted of 5backend systems: a legacy e-commerce platform, an e-commerce pricing service, a financial services system, a portal API, and an enquiries database.
Show each system had its own unique characteristicsHide each system had its own unique characteristics
The five backend systems presented a complex integration challenge:
- E-commerce platform - REST API with inconsistent JSON types (integers, floats, strings for the same field across different responses)
- E-commerce pricing service - Bearer token authentication returning session cookies for downstream APIs
- Financial services system - Portfolio holdings and account projections, optional for new users
- Portal API - Customer account details and transaction history
- Enquiries database - Legacy system with ad-hoc query interfaces
Each system used different authentication mechanisms (bearer tokens, cookies, API keys), different date formats, and different approaches to error handling. The API layer needed to abstract these differences while maintaining type safety.
Technical Requirements
- Stateless authentication using existing bearer tokens from the e-commerce platform
- Multi-system aggregation combining data from 5 separate backend APIs
- Type-safe transformation of inconsistent legacy responses into strongly-typed DTOs
- API stability guarantees preventing breaking changes to the contract
- OpenAPI documentation for AI consumer integration
Show why API stability was critical for this projectHide why API stability was critical for this project
Unlike traditional web applications where breaking changes can be deployed alongside frontend updates, AI integrations present unique challenges for API evolution.
The external AI assistant was developed and deployed independently by a separate team. Breaking changes to our API could cause integration failures that would be difficult to diagnose and resolve, as we had no direct control over the AI's update schedule.
The AI also consumed the OpenAPI specification to understand available endpoints and required parameters. Any deviation between the specification and actual behaviour would result in the AI making malformed requests or misinterpreting responses.
This meant we needed automated detection of breaking changes before they reached production, with clear guidance on when version bumps were required. Manual code review alone was insufficient as the API grew to 27 endpoints with complex nested schemas.
Ready to eliminate your technical debt?
Transform unmaintainable legacy code into a clean, modern codebase that your team can confidently build upon.
The Solution
We built a Symfony REST API serving as an intelligent gateway between the AI assistant and the legacy e-commerce systems. The architecture follows a layered approach with single-action controllers, domain services, and external API adapters.
Core Components
- 28single-action controllers - Each endpoint is an invocable class with OpenAPI attributes
- 25service classes - Domain logic separated from HTTP concerns
- 42DTOs - Immutable value objects with readonly properties
- 39custom exceptions - Hierarchical exception structure organised by external system
Show view technology stack detailsHide view technology stack details
The platform uses modern PHP 8.1+ features including strict typing, readonly properties, and named arguments. Key libraries include:
- Doctrine ORM for database interaction
- moneyphp/money (v4.7) for proper monetary value handling
- NelmioApiDocBundle (v5.4) for OpenAPI specification generation
- PSR-18 HTTP clients with retry mechanisms for resilient external API calls
- Ramsey UUID for entity identification
Quality tooling includes PHPUnit for size-categorised tests (Small/Medium/Large), PHPStan for static analysis, PHP CS Fixer for code style, and GrumPHP for pre-commit hooks.
Bearer Token Authentication
The authentication system validates bearer tokens against external APIs without storing credentials locally. Each request triggers a three-stage validation flow:
Show why external validation instead of local credentials?Hide why external validation instead of local credentials?
Traditional web applications store user credentials in a local database and validate authentication requests against that database. This approach was not viable for several reasons:
- No user database available - The API was designed as a stateless gateway, not a full user management system
- Single source of truth - The e-commerce platform's existing authentication system was the authoritative source for token validity
- Synchronisation complexity - Maintaining a local copy of user credentials would require complex synchronisation logic and introduce consistency risks
- Security concerns - Storing credentials locally multiplies the attack surface and compliance requirements
Instead, the API acts as a proxy, validating tokens by calling the e-commerce platform's existing authentication endpoint. This keeps the API stateless while ensuring authentication decisions align with the platform's security policies.
- Token format validation - Strict checking for 40-character lowercase hexadecimal format
- E-commerce API validation - Token exchange for session cookies
- E-commerce platform + financial services data fetch - User details and portfolio information retrieval
The system implements a 15-minute cache for authenticated users, achieving approximately 90% cache hit rate for repeated requests within the same session.
Show how the 15-minute cache TTL was chosenHide how the 15-minute cache TTL was chosen
The cache TTL balances three competing concerns:
- External API load - Each uncached authentication requires three separate API calls (e-commerce, legacy platform, financial services). Without caching, a user making 10 requests in quick succession would trigger 30 external calls.
- Data freshness - User details (vault balances, account status) change infrequently. Most changes can tolerate a 15-minute delay.
- Security revocation - If a token is revoked (user logs out, security event), the cache could serve stale data for up to 15 minutes. This was deemed acceptable given the low-risk nature of the exposed data.
The 15-minute TTL works for typical AI interaction sessions and lets vault balances update reasonably quickly. Longer TTLs (30-60 minutes) would reduce API load but show stale financial data.
The authentication flow combines these components to validate tokens efficiently whilst maintaining security.
The exception handling gives users and operations teams the diagnostic information they need.
Show exception hierarchy design philosophyHide exception hierarchy design philosophy
The authentication system uses a hierarchical exception structure organised by the external system that failed:
TokenNotFoundException- E-commerce API rejected the tokenMagentoUserNotFoundException- Token valid but user missing in Magento (sync issue)SoaNotFoundException- User has no SOA vault records (expected for new users)
Each exception maps to a specific HTTP status code and error message:
TokenNotFoundException→ 401 Bad Credentials (user should re-authenticate)MagentoUserNotFoundException→ 503 Service Unavailable (temporary sync issue, contact support)SoaNotFoundException→ Handled gracefully (empty vault collection, no error)
This granular approach enables the AI to provide appropriate user feedback instead of generic "authentication failed" messages. It also helps operations teams diagnose issues quickly by identifying which backend system caused the failure.
Show security measures beyond authenticationHide security measures beyond authentication
The authentication system implements several security measures beyond basic token validation:
- Token masking in logs - Only the first 4 and last 4 characters are logged, preventing token leakage while enabling support team debugging
- Log level attributes - Missing authorization headers log at INFO level (expected behaviour), while server errors log at ERROR level (requires investigation)
- Environment-aware error detail exposure - Development environments expose full exception details, production returns sanitised messages
- Rate limiting considerations - The 15-minute cache naturally rate limits authentication attempts (failed tokens aren't cached)
These measures balance security with debuggability, reducing alert fatigue while ensuring genuine security events are visible to operations teams.
Ready to eliminate your technical debt?
Transform unmaintainable legacy code into a clean, modern codebase that your team can confidently build upon.
Automated Backward Compatibility Checking
The AI consumer depends on a stable API contract. Any breaking change could disrupt integration and cause service failures. Manual review was insufficient as the API grew to 27paths with complex request/response schemas.
We implemented an OpenAPI-based comparison system that detects 13breaking change categories automatically:
- Endpoint removal
- HTTP method removal
- Required parameter changes (removal, type changes, new required parameters)
- Request body schema changes
- Response schema modifications
- Content-type removal
Changes are classified into three levels: breaking changes (exit code 1), warnings (optional failure), and safe changes (informational only).
Show how baseline management worksHide how baseline management works
The system maintains a baseline OpenAPI specification representing the current API contract. When changes are approved, the baseline is updated and archived with a timestamp:
baseline/
├── openapi.json # Current baseline
└── history/
├── openapi_2025-11-15_143022.json
├── openapi_2025-11-20_091455.json
└── openapi_2025-12-01_164833.jsonThis timestamped history enables audit trails and rollback capabilities if issues are discovered after deployment.
Custom Serialisation for Legacy APIs
The legacy e-commerce API returns product data with inconsistent types: prices as integers, floats, or strings; booleans as strings; nested structures with optional intermediate levels. Standard Symfony deserialisation would fail or produce incorrect data.
We implemented a high-priority custom denormaliser that handles type coercion, nested extraction, and graceful degradation before delegating to the standard Symfony serialiser.
Type Coercion Challenges
- Prices - Integer (100), float (99.95), string ('99.99'), or non-numeric ('not-a-price')
- Booleans - True boolean, integer (0/1), or string ('true', 'false', '0', '1', 'yes', 'no')
- Arrays - Empty array, null, or non-existent
- Nested structures - Multiple levels of wrapping with optional intermediate objects
Show concrete examples of type inconsistenciesHide concrete examples of type inconsistencies
The legacy e-commerce API's type inconsistencies were not theoretical. They appeared in real production responses:
- Price field variations:
- Product A:
"price": 100(integer) - Product B:
"price": 99.95(float) - Product C:
"price": "99.99"(string) - Product D:
"final_price": "<span class=\"price\">47.22 USD$</span>"(HTML)
- Product A:
- Boolean inconsistencies:
"in_stock": true(boolean)"in_stock": 1(integer)"in_stock": "true"(string)"is_new": "0"(string zero)
- Category field chaos:
"categories": ["Gold", "Silver"](string array)"categories": [123, 456](integer array)"categories": ["Gold", 123](mixed types!)
These variations likely arose from different code paths in the legacy platform. Some endpoints returned raw database values (integers), others ran through view helpers (strings or HTML), and older code paths used different type conventions. The denormaliser needed to handle all variations reliably.
Handling this range of type variations required a custom denormaliser that coerces data whilst preserving type safety.
The legacy platform's nested response structure makes serialisation more complex.
Show why the nested structure is so complexHide why the nested structure is so complex
The legacy e-commerce product response has an unusual structure that wraps products in multiple levels of nesting:
{
"right": "ignored presentation content",
"left": {
"products": {
"1761": {
"tiers": { "tiers": [ {...}, {...} ] },
"product": { "entity_id": "1761", ... }
}
}
}
}This structure comes from the legacy platform's frontend presentation layer, where "right" contains sidebar HTML and "left" contains main content. The API exposed this internal structure directly rather than providing a clean data-only response.
The denormaliser extracts products from data['left']['products'], discards the "right" content, and handles cases where intermediate levels (left, products) might be missing, null, or malformed. This defensive approach prevents API failures when the legacy platform's response structure varies.
The system handles 88DTO fields with 43nullable properties, gracefully skipping invalid entries rather than failing completely.
Show why graceful degradation instead of strict validation?Hide why graceful degradation instead of strict validation?
Traditional API design favours strict validation: if the input doesn't match the schema, reject it entirely. This approach made sense when we controlled the data source.
However, with legacy e-commerce APIs, we faced a different situation. The upstream system was outside our control, and its data quality varied. Products might have 10 pricing tiers with tier 7 containing an invalid price format.
Failing the entire product request because one tier was malformed would prevent the AI from showing any pricing information to the user. Instead, the denormaliser skips the invalid tier and returns the 9 valid tiers.
Invalid entries are logged with details about what was skipped and why. This lets the platform team investigate and fix data quality issues without impacting live AI interactions. This approach prioritises availability and user experience over perfect data consistency.
The trade-off: users might occasionally see incomplete information (9 tiers instead of 10), but they never see complete failures due to minor data quality issues in systems we don't control.
Show price storage decision: strings vs Money objectsHide price storage decision: strings vs Money objects
Prices are stored as strings in the DTOs rather than being immediately converted to Money objects from the moneyphp/money library. This decision was driven by the legacy API's behaviour.
The legacy e-commerce API sometimes returns prices as HTML-formatted strings: <span class="price">47.22 USD$</span>. Immediate conversion to Money objects would require complex HTML parsing or result in data loss.
By storing prices as strings in DTOs, we preserve the original data exactly as received. Service layer code can then decide how to interpret each price field:
- Numeric strings (
"99.99") convert cleanly to Money objects - HTML strings parse to extract the numeric value or fall back to another field
- Invalid formats log warnings and use default values
This approach separates concerns: the denormaliser handles type coercion to get strings, while services handle business logic about which price field to use and how to interpret it. The trade-off is that services must explicitly convert strings to Money objects, but this is acceptable given the complexity of the upstream data.
Ready to eliminate your technical debt?
Transform unmaintainable legacy code into a clean, modern codebase that your team can confidently build upon.
Results & Outcomes
The API platform successfully bridges the AI assistant and the legacy e-commerce systems. It enables conversational commerce while maintaining security and stability.
Measurable Outcomes
- ~90%cache hit rate - 15-minute user cache covers most session activity
- <10msauth latency (cached) - Local cache lookup performance
- 200-500msauth latency (uncached) - Coordinates three external API calls
- 13breaking change categories - Automatically detected by BC checker
- 88product DTO fields - Type-safe transformation of legacy responses
What Worked Well
- Caching strategy - Reduced external API load significantly without sacrificing freshness
- Exception hierarchy - Clear error codes enabled appropriate AI feedback
- BC automation - Caught multiple potential breaking changes during development
- Type coercion - Graceful handling of legacy data types improved reliability
- Size-categorised tests - Fast feedback during development (Small/Medium/Large)
Lessons Learned
Several engineering decisions proved valuable beyond their initial scope:
- Strict token format validation - Caught many malformed requests early, preventing ambiguous authentication states
- Graceful tier degradation - Skipping invalid tiers rather than failing improved resilience for incomplete data
- Context-based recursion prevention - Essential pattern for custom denormalisers that delegate to standard serialisers
- Log level attributes - Reduced alert fatigue by classifying expected vs unexpected failures
- Baseline history archiving - Timestamped OpenAPI specs proved valuable for debugging BC check issues
Show how test categorisation improved development velocityHide how test categorisation improved development velocity
The project used PHPUnit's size annotation to categorise tests into three tiers: Small (unit tests), Medium (integration tests), and Large (end-to-end tests).
Small tests run in isolation with all dependencies mocked, completing in milliseconds. These formed the bulk of the test suite and provided instant feedback during development. Developers could run phpunit --group small in under 5 seconds, catching most bugs immediately.
Medium tests interact with real services (Symfony's serialiser, cache layer) but mock external APIs. These verify integration between components without network overhead, running in a few seconds.
Large tests exercise the full system including external API calls (using recorded fixtures). These run in CI only, as they take minutes to complete. They catch integration issues that unit tests miss.
This tiered approach meant developers got feedback in seconds (Small), integration confidence in tens of seconds (Small + Medium), and full system validation in CI (all sizes). The fast inner loop dramatically improved development velocity compared to running the full suite on every change.
Beyond development velocity, the project involved multiple architectural trade-offs that balanced competing technical and business requirements.
Show view trade-offs and design decisionsHide view trade-offs and design decisions
Several trade-offs were made to balance competing concerns:
- 15-minute cache TTL - Balance between data freshness and API load; user changes may take up to 15 minutes to reflect
- Prices stored as strings - Preserved precision and handled HTML-formatted prices from legacy API, but requires conversion for calculations
- Strict token format - Rejects edge cases but prevents ambiguous authentication states
- Synchronous multi-API auth - Simpler than async orchestration but adds latency to first request (mitigated by caching)
These decisions demonstrate engineering judgement beyond standard patterns, prioritising system resilience and developer experience over theoretical purity.
One particularly important architectural decision was the choice of Symfony's authentication passport type, which proved essential for the external validation pattern.
Show why SelfValidatingPassport was the right choiceHide why SelfValidatingPassport was the right choice
Symfony's Security component offers several passport types for authentication: PasswordCredentials (validate against local database), UserBadge (fetch user by identifier), and SelfValidatingPassport (trust the authenticator's validation).
For this project, SelfValidatingPassport was essential because the e-commerce API was the source of truth for token validity. The API didn't store credentials locally, so PasswordCredentials made no sense. UserBadge alone would require implementing credential checking logic that duplicated the external API's validation.
SelfValidatingPassport tells Symfony: "The UserProvider has already validated this user during the loadUserByIdentifier call. Trust that validation." This fits perfectly with the external validation flow. By the time loadUserByIdentifier returns, we know the token is valid (it was exchanged for cookies successfully).
This pattern avoids redundant validation while maintaining Symfony's security abstractions. The authenticator remains stateless, the UserProvider handles the complex multi-API coordination, and Symfony's firewall manages access control rules based on the authenticated user object.
Related Services
Services used in this project
Ready to eliminate your technical debt?
Transform unmaintainable legacy code into a clean, modern codebase that your team can confidently build upon.
Need to integrate AI with your e-commerce platform?
Let's discuss how we can build secure, scalable APIs for your AI initiatives.