spipu/api-partner-bundle
Symfony bundle for managing API partners: create partner accounts, issue and validate API keys, and secure your REST endpoints. Provides Doctrine entities, configuration, and security integration for authenticating third-party API clients.
Install and wire it up
composer require spipu/api-partner-bundle:^5.1
If Flex doesn't auto-register the bundle, add it to config/bundles.php:
Spipu\ApiPartnerBundle\SpipuApiPartnerBundle::class => ['all' => true],
Then publish/define the config. Check vendor/spipu/api-partner-bundle/Resources/config/ first — that directory (and the bundle's DependencyInjection/Configuration.php) is the ground truth for option names, since the README is thin and there is no recipe to lean on.
Where to look first
DependencyInjection/Configuration.php — the real config schema, including the log-retention options added for the cleanup step.Resources/config/services.yaml — service IDs you can override or decorate, and the process/cleanup step registration.Entity/ directory — the Partner model and its Doctrine mapping, plus the repository, and the log entity the bundle persists to.Command/ or Controller/ shipped with it — quickest path to creating your first partner record and to running the log purge by hand.First use case
The canonical flow is: a Partner row holds a public identifier (API key) and a secret/hash; a firewall authenticates incoming requests from that partner and hands you back a Partner object as the security user.
/api/ping controller with the partner's key in the header and confirm $this->getUser() is a Partner.Dedicated stateless firewall. Keep partner traffic on its own firewall so you never accidentally start a session for machine clients:
# config/packages/security.yaml
security:
firewalls:
api_partner:
pattern: ^/api/partner
stateless: true
provider: spipu_api_partner_provider # verify the exact provider ID in services.yaml
custom_authenticators:
- Spipu\ApiPartnerBundle\Security\...Authenticator
Isolating the firewall also means the Partner user never leaks into your browser-session firewalls.
Partner lifecycle as a first-class concern. Treat key issuance as a service, not an ad-hoc repository save. A small PartnerManager around the bundle's repository gives you: create(name, scopes) → returns plaintext key once, rotate(partner), revoke(partner). Store only the hash; show the plaintext exactly once at creation time. This is the single most important pattern — the bundle gives you the model, but rotation/revocation policy is yours.
Scope checks at the controller boundary. Rather than layering full RBAC on top, map partner scopes (or roles) to route requirements with a small voter or a #[IsGranted] attribute so a read-only partner can't hit write endpoints. Keep the check declarative; it's much easier to audit than inline if in controllers.
Multi-tenancy. If partners own data, put a partner_id on your domain tables and set it from the authenticated user in a Doctrine listener or a request-scoped service. Do not rely on the client to send its own partner ID — derive it from the authenticator every time.
Observability is now built in — schedule the purge. As of v5.1.1 the bundle ships a CleanLogs process step that deletes partner log rows beyond a configurable window. Two things follow:
spipu/api-loggable or hand-roll a kernel.terminate subscriber — configure the bundle's logging and query its log table for partner ID, route, status, and latency dashboards.symfony/scheduler, not on the request path:// config/packages/scheduler.yaml or a cron entry
// php bin/console <clean-logs-command> (find the exact name via `bin/console list`)
$schedule->command('<clean-logs-command>')->dailyAt('03:15');
Pick the retention window from your compliance policy first, then set the config — not the other way round. If you must keep 400-day audit trails, disable the purge and archive into cold storage instead.
Testing. In functional tests, either (a) create a partner in a fixture and send the real header via $client->request(..., server: ['HTTP_X_API_KEY' => $key]), or (b) for unit tests, construct the token/Partner directly and inject it. Prefer (a) for the authenticator itself and (b) everywhere else — it keeps test suites fast. Add one test that asserts the purge removes exactly the rows past the horizon, since it's destructive.
Pin the 5.x line, and read the changelog between minors. The current major is 5.x; a ^1.0 constraint is stale and will not resolve. Use ^5.1 for normal use, and pin exactly (5.1.1) if you depend on the log table's schema. The project is small (the v5.1.1 fix arrived as PR #1), so treat minor bumps as reviewable diffs rather than automatic updates — verify the tag matches the commit you install.
The purge step is destructive — test it before you trust it. Run the clean-logs command manually against a staging copy, confirm the boundary (rows exactly at the retention edge), and confirm it does not touch rows belonging to partners you've deliberately retained for an open investigation. Also confirm it is batched or limits its delete; an unbounded DELETE on a large log table can lock it.
Log volume is now your problem. Turning on partner logging means a row per request. Before you enable it in production, estimate rows/day from expected traffic and size the table and its indexes accordingly; index partner_id and created_at so both the dashboards and the purge are cheap. If you only need aggregate metrics, consider sampling or logging only non-2xx responses.
Never log the raw key — the bundle writes to a table you own. Symfony's Monolog request processor and most APM tools happily capture all headers, and the bundle's own log rows may capture request context. Add a redaction rule for the API key header, inspect what actually lands in the log table on a test request, and double-check var_dump/dump calls don't survive into a controller you ship. A leaked key in a log table with 90-day retention is a 90-day incident.
Doctrine mapping conflicts. If you already have a Partner/ApiClient entity, do not run two mappings for the same table. Use the bundle's MappedSuperclass (if provided) and extend it, or remap the bundle entity to your table via config. Check for a resolve_target_entities-style option in Configuration.php before duplicating fields. The same applies to the log entity if you already have an audit table.
Hash at rest, compare in constant time. Any place you compare a presented key against a stored value should go through hash_equals(). If the bundle stores keys in plaintext for lookup, consider a two-column scheme (indexable hash + secret hash) rather than relaxing that rule.
Proxy and IP allowlisting. If the bundle supports per-partner IP restrictions, Request::getClientIp() returns the proxy IP unless framework.trusted_proxies and trusted_headers are configured. This is the most common "it works locally, it 403s in staging" failure for this class of bundle.
Symfony/security version drift. The enable_authenticator_manager flag, custom_authenticators vs. the old guard key, and the security.user.provider naming all changed across 5.4 → 6.x → 7.x. Match the bundle's composer.json constraints to your Symfony version first; don't debug the authenticator when the real problem is a constraint mismatch.
Extension points worth knowing. Override the entity via config (superclass/class mapping), decorate the authenticator service to add logging or custom header parsing, replace the provider if partner lookup needs to hit Redis or an external service, and adjust or replace the CleanLogs step if its deletion policy doesn't match yours. All are one-line YAML changes — find the service IDs in Resources/config/services.yaml rather than copying classes around.
Rate limiting is still not included. The log table tells you a partner is hammering you after the fact. Add Symfony's RateLimiter keyed on the authenticated partner ID in a listener on kernel.request, ordered after the firewall. Keying on IP is wrong here — partners are the identity.
How can I help you explore Laravel packages today?