kikwik/iptraceable-listener-bundle
Symfony bundle that automatically records the client IP address on Doctrine entities implementing IpTraceable, using an event listener to populate IP fields on create and update. Simplifies audit trails and traceability with minimal configuration.
This is a Symfony bundle (not Laravel) — the Gedmo IP-traceable equivalent of Timestampable/Blameable. It wires Gedmo\IpTraceable\IpTraceableListener to the current request so a column is automatically filled with the client IP on persist/update.
Minimal setup:
Install (no Flex recipe, so no auto-config):
composer require kikwik/iptraceable-listener-bundle
Pin the version — the only release is from 2021, so it targets Symfony 4/5 + PHP 7.x. On Symfony 6/7 expect a fork or a composer.json constraint tweak.
Register the bundle in config/bundles.php:
return [
// ...
Kikwik\IpTraceableListenerBundle\KikwikIpTraceableListenerBundle::class => ['all' => true],
];
Enable the Gedmo extension (if you also use stof/doctrine-extensions-bundle):
# config/packages/stof_doctrine_extensions.yaml
stof_doctrine_extensions:
orm:
default:
ip_traceable: true
Inspect what got registered before writing any code — this is the first thing to do on an undocumented 0-star bundle:
bin/console debug:container | grep -i ip
bin/console debug:event-dispatcher kernel.request
First use case — stamp the IP on any audit-relevant entity:
use Gedmo\Mapping\Annotation as Gedmo;
class LoginAttempt
{
/**
* @ORM\Column(type="string", length=45, nullable=true)
* @Gedmo\IpTraceable(on="update")
*/
private ?string $lastModifiedIp = null;
public function getLastModifiedIp(): ?string { return $this->lastModifiedIp; }
}
Length 45 is the correct size for IPv6 (a common first mistake is length=15, which silently truncates or throws).
Reusable trait for a base entity. Almost every project wants the same pair of columns:
trait IpTraceableTrait
{
/** @Gedmo\IpTraceable(on="create") @ORM\Column(length=45, nullable=true) */
private ?string $createdFromIp = null;
/** @Gedmo\IpTraceable(on="update") @ORM\Column(length=45, nullable=true) */
private ?string $updatedFromIp = null;
}
on="create", on="update", on="change" (only when a mapped field actually changes) map directly to Gedmo semantics — reuse them rather than adding a custom subscriber.
Pair it with Blameable. IP alone is weak audit data (NAT, proxies, VPN). The idiomatic combo is Gedmo\Blameable for who plus Gedmo\IpTraceable for from where:
/** @Gedmo\Blameable(on="update") @ORM\Column(length=180, nullable=true) */
private ?string $updatedBy = null;
Trusted proxies must be configured first. The listener reads $request->getClientIp(), which returns the proxy IP unless framework.trusted_proxies / trusted_headers are set. Behind a load balancer, verify with a log line before trusting the values:
framework:
trusted_proxies: '%env(TRUSTED_PROXIES)%'
trusted_headers: ['x-forwarded-for', 'x-forwarded-proto']
Normalize or anonymize at the edge. If GDPR requires hashing or truncating (e.g. /24), decorate the listener or add a higher-priority kernel.request subscriber that calls setIpValue() with the transformed value — the Gedmo listener accepts whatever string you give it.
Functional test assertion. IPs only populate when a request actually runs through the kernel, so an integration test works:
$client->request('POST', '/api/orders', ['json' => [...]]);
$attempt = $em->getRepository(LoginAttempt::class)->findLast();
$this->assertSame('127.0.0.1', $attempt->getLastModifiedIp());
A plain $em->persist() in a unit test will leave the field null — that is expected, not a bug.
Abandoned/low-adoption risk. 0 stars, 0 dependents, last release 2021-07-16. Treat the dependency as a stopgap: the bundle's entire job is roughly 25 lines of kernel.request subscriber calling setIpValue($request->getClientIp()). Budget a half-day to replace it with in-house code before upgrading Symfony majors.
The listener is a singleton with mutable state. setIpValue() stores the IP on a shared service. Under a classic FPM request that's fine; under Roadrunner, Swoole, FrankenPHP, or Messenger workers, request A's IP can leak into request B unless the value is reset on kernel.terminate or each message sets it explicitly (usually to null).
CLI and queue contexts have no request. Console commands, cron jobs, and Messenger handlers will persist with a null IP. That's why nullable=true is mandatory — a NOT NULL column will blow up the first time a worker saves an entity.
Bulk DQL/DBAL writes bypass it entirely. UPDATE ... SET ... via EntityManager::createQuery or raw SQL never touches the UnitOfWork, so no IP is recorded. Same for ->flush() on entities not managed by the EM at that moment.
Check for double registration. If both stof/doctrine-extensions-bundle and this bundle register an IpTraceableListener, you can get two listeners on the same event, or a compiler-pass error. Confirm with debug:container --tag=doctrine.event_subscriber | grep -i ip and disable ip_traceable in stof if this bundle owns it (or vice versa).
Annotation vs attribute support. Gedmo moved to PHP attributes in v3.7+. If Doctrine annotations are disabled (doctrine/annotations absent, common on DoctrineBundle 2.10+/ORM 2.14+), @Gedmo\IpTraceable will be silently ignored. Use #[Gedmo\IpTraceable(on: 'update')] on newer stacks.
Debugging "the column is always null": check in this order — (1) debug:event-dispatcher shows the request subscriber, (2) debug:container shows the listener, (3) the extension is enabled in stof/YAML, (4) the annotation/attribute is on the property, not the getter, (5) the entity is actually dirty and flushed in a request context.
Not a security control. IPs are spoofable if trusted_proxies is too permissive and should never drive authorization, rate limiting, or account lockout by themselves. Store them as audit metadata only.
Privacy. Raw IP is personal data under GDPR. Decide up front whether to hash (hash('sha256', $ip), column length=64) or store the raw address with a defined retention window — retrofitting this later means rewriting the column and backfilling.
How can I help you explore Laravel packages today?