diff --git a/.editorconfig b/.editorconfig
index e49ab5718..beebd2703 100644
--- a/.editorconfig
+++ b/.editorconfig
@@ -21,3 +21,7 @@ indent_size = 4
[*.yml]
indent_style = space
indent_size = 4
+
+[*.xml]
+indent_style = space
+indent_size = 4
diff --git a/.env.dist b/.env.dist
index 881374f80..015ea10b6 100644
--- a/.env.dist
+++ b/.env.dist
@@ -45,3 +45,7 @@ APP_SECRET=change_this_to_something_unique
#================================================================================
# unlikely, that you need to change this one
CORS_ALLOW_ORIGIN=^https?://localhost(:[0-9]+)?$
+
+###> symfony/messenger ###
+MESSENGER_TRANSPORT_DSN=doctrine://default?auto_setup=0
+###< symfony/messenger ###
diff --git a/composer.json b/composer.json
index a651e132d..2c1f5998a 100644
--- a/composer.json
+++ b/composer.json
@@ -74,6 +74,7 @@
"symfony/translation": "^6.0",
"symfony/twig-bundle": "^6.0",
"symfony/validator": "^6.0",
+ "symfony/webhook": "^6.0",
"symfony/webpack-encore-bundle": "^2.0",
"symfony/yaml": "^6.0",
"twig/cssinliner-extra": "^3.0",
diff --git a/composer.lock b/composer.lock
index 02231ce8f..4d27264a8 100644
--- a/composer.lock
+++ b/composer.lock
@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
- "content-hash": "9f49e3cfd10e3bdd29e6bd67968ec19b",
+ "content-hash": "9d1ceee2eb5d9ad5d14add5cd65f49d2",
"packages": [
{
"name": "azuyalabs/yasumi",
@@ -7725,6 +7725,97 @@
],
"time": "2025-10-24T13:29:09+00:00"
},
+ {
+ "name": "symfony/messenger",
+ "version": "v6.4.28",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/symfony/messenger.git",
+ "reference": "6a758210a2392c4a326de84a6c8c70e7e18296f9"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/symfony/messenger/zipball/6a758210a2392c4a326de84a6c8c70e7e18296f9",
+ "reference": "6a758210a2392c4a326de84a6c8c70e7e18296f9",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.1",
+ "psr/log": "^1|^2|^3",
+ "symfony/clock": "^6.3|^7.0",
+ "symfony/deprecation-contracts": "^2.5|^3"
+ },
+ "conflict": {
+ "symfony/console": "<6.3",
+ "symfony/event-dispatcher": "<5.4",
+ "symfony/event-dispatcher-contracts": "<2.5",
+ "symfony/framework-bundle": "<5.4",
+ "symfony/http-kernel": "<5.4",
+ "symfony/serializer": "<5.4"
+ },
+ "require-dev": {
+ "psr/cache": "^1.0|^2.0|^3.0",
+ "symfony/console": "^6.3|^7.0",
+ "symfony/dependency-injection": "^5.4|^6.0|^7.0",
+ "symfony/event-dispatcher": "^5.4|^6.0|^7.0",
+ "symfony/http-kernel": "^5.4|^6.0|^7.0",
+ "symfony/process": "^5.4|^6.0|^7.0",
+ "symfony/property-access": "^5.4|^6.0|^7.0",
+ "symfony/rate-limiter": "^5.4|^6.0|^7.0",
+ "symfony/routing": "^5.4|^6.0|^7.0",
+ "symfony/serializer": "^5.4|^6.0|^7.0",
+ "symfony/service-contracts": "^2.5|^3",
+ "symfony/stopwatch": "^5.4|^6.0|^7.0",
+ "symfony/validator": "^5.4|^6.0|^7.0"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "Symfony\\Component\\Messenger\\": ""
+ },
+ "exclude-from-classmap": [
+ "/Tests/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Samuel Roze",
+ "email": "samuel.roze@gmail.com"
+ },
+ {
+ "name": "Symfony Community",
+ "homepage": "https://symfony.com/contributors"
+ }
+ ],
+ "description": "Helps applications send and receive messages to/from other applications or via message queues",
+ "homepage": "https://symfony.com",
+ "support": {
+ "source": "https://github.com/symfony/messenger/tree/v6.4.28"
+ },
+ "funding": [
+ {
+ "url": "https://symfony.com/sponsor",
+ "type": "custom"
+ },
+ {
+ "url": "https://github.com/fabpot",
+ "type": "github"
+ },
+ {
+ "url": "https://github.com/nicolas-grekas",
+ "type": "github"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2025-11-06T11:12:43+00:00"
+ },
{
"name": "symfony/mime",
"version": "v6.4.30",
@@ -8595,6 +8686,75 @@
],
"time": "2025-07-10T08:14:14+00:00"
},
+ {
+ "name": "symfony/remote-event",
+ "version": "v6.4.24",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/symfony/remote-event.git",
+ "reference": "920e68c4f09f460609cf39a3540f1aa19da72c21"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/symfony/remote-event/zipball/920e68c4f09f460609cf39a3540f1aa19da72c21",
+ "reference": "920e68c4f09f460609cf39a3540f1aa19da72c21",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.1",
+ "symfony/messenger": "^5.4|^6.1|^7.0"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "Symfony\\Component\\RemoteEvent\\": ""
+ },
+ "exclude-from-classmap": [
+ "/Tests/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Fabien Potencier",
+ "email": "fabien@symfony.com"
+ },
+ {
+ "name": "Symfony Community",
+ "homepage": "https://symfony.com/contributors"
+ }
+ ],
+ "description": "Eases handling remote events",
+ "homepage": "https://symfony.com",
+ "keywords": [
+ "event"
+ ],
+ "support": {
+ "source": "https://github.com/symfony/remote-event/tree/v6.4.24"
+ },
+ "funding": [
+ {
+ "url": "https://symfony.com/sponsor",
+ "type": "custom"
+ },
+ {
+ "url": "https://github.com/fabpot",
+ "type": "github"
+ },
+ {
+ "url": "https://github.com/nicolas-grekas",
+ "type": "github"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2025-07-10T08:14:14+00:00"
+ },
{
"name": "symfony/routing",
"version": "v6.4.30",
@@ -10131,6 +10291,78 @@
],
"time": "2025-09-11T09:57:09+00:00"
},
+ {
+ "name": "symfony/webhook",
+ "version": "v6.4.24",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/symfony/webhook.git",
+ "reference": "95d6f437a17224029983f2bed3cfaca09e530a17"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/symfony/webhook/zipball/95d6f437a17224029983f2bed3cfaca09e530a17",
+ "reference": "95d6f437a17224029983f2bed3cfaca09e530a17",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.1",
+ "symfony/http-foundation": "^6.3|^7.0",
+ "symfony/http-kernel": "^6.3|^7.0",
+ "symfony/messenger": "^5.4|^6.1|^7.0",
+ "symfony/remote-event": "^6.3|^7.0"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "Symfony\\Component\\Webhook\\": ""
+ },
+ "exclude-from-classmap": [
+ "/Tests/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Fabien Potencier",
+ "email": "fabien@symfony.com"
+ },
+ {
+ "name": "Symfony Community",
+ "homepage": "https://symfony.com/contributors"
+ }
+ ],
+ "description": "Eases sending and consuming webhooks",
+ "homepage": "https://symfony.com",
+ "keywords": [
+ "webhook"
+ ],
+ "support": {
+ "source": "https://github.com/symfony/webhook/tree/v6.4.24"
+ },
+ "funding": [
+ {
+ "url": "https://symfony.com/sponsor",
+ "type": "custom"
+ },
+ {
+ "url": "https://github.com/fabpot",
+ "type": "github"
+ },
+ {
+ "url": "https://github.com/nicolas-grekas",
+ "type": "github"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2025-07-10T08:14:14+00:00"
+ },
{
"name": "symfony/webpack-encore-bundle",
"version": "v2.4.0",
diff --git a/config/packages/messenger.yaml b/config/packages/messenger.yaml
new file mode 100644
index 000000000..19db483da
--- /dev/null
+++ b/config/packages/messenger.yaml
@@ -0,0 +1,22 @@
+framework:
+ messenger:
+ # Uncomment this (and the failed transport below) to send failed messages to this transport for later handling.
+ # failure_transport: failed
+
+ transports:
+ # https://symfony.com/doc/current/messenger.html#transport-configuration
+ # async: '%env(MESSENGER_TRANSPORT_DSN)%'
+ # failed: 'doctrine://default?queue_name=failed'
+ sync: 'sync://'
+
+ routing:
+ # Route your messages to the transports
+ # 'App\Message\YourMessage': async
+
+# when@test:
+# framework:
+# messenger:
+# transports:
+# # replace with your transport name here (e.g., my_transport: 'in-memory://')
+# # For more Messenger testing tools, see https://github.com/zenstruck/messenger-test
+# async: 'in-memory://'
diff --git a/config/routes/webhook.yaml b/config/routes/webhook.yaml
new file mode 100644
index 000000000..aa981e77e
--- /dev/null
+++ b/config/routes/webhook.yaml
@@ -0,0 +1,3 @@
+#webhook:
+# resource: '@FrameworkBundle/Resources/config/routing/webhook.xml'
+# prefix: /webhook
diff --git a/phpunit.xml.dist b/phpunit.xml.dist
index 577d748af..6bc79f5f2 100644
--- a/phpunit.xml.dist
+++ b/phpunit.xml.dist
@@ -38,6 +38,13 @@
GRANT execute,select,insert,update,delete,create,alter,drop,index,references ON `kimai2_test`.* TO kimai2_test@127.0.0.1;
-->
+
+
+
+
+
+
+
diff --git a/src/Entity/WebhookConfiguration.php b/src/Entity/WebhookConfiguration.php
new file mode 100644
index 000000000..39cb5e45f
--- /dev/null
+++ b/src/Entity/WebhookConfiguration.php
@@ -0,0 +1,60 @@
+name = $name;
+ $this->url = $url;
+ $this->transport = $transport;
+ $this->secret = $secret;
+ $this->authentication = $authentication;
+ }
+
+ public function getName(): string
+ {
+ return $this->name;
+ }
+
+ public function getUrl(): string
+ {
+ return $this->url;
+ }
+
+ public function getTransport(): string
+ {
+ return $this->transport;
+ }
+
+ public function getSecret(): string
+ {
+ return $this->secret;
+ }
+
+ public function getAuthentication(): string
+ {
+ return $this->authentication;
+ }
+}
diff --git a/src/Entity/WebhookEvent.php b/src/Entity/WebhookEvent.php
new file mode 100644
index 000000000..7b4ab9618
--- /dev/null
+++ b/src/Entity/WebhookEvent.php
@@ -0,0 +1,32 @@
+name = $name;
+ $this->configuration = $configuration;
+ }
+
+ public function getName(): string
+ {
+ return $this->name;
+ }
+
+ public function getConfiguration(): WebhookConfiguration
+ {
+ return $this->configuration;
+ }
+}
diff --git a/src/Serializer/Serializer.php b/src/Serializer/Serializer.php
new file mode 100644
index 000000000..4a8328735
--- /dev/null
+++ b/src/Serializer/Serializer.php
@@ -0,0 +1,64 @@
+setGroups($groups);
+ $jmsContext->enableMaxDepthChecks();
+ }
+
+ public function toArray(mixed $data, array $context = []): array
+ {
+ $jmsContext = SerializationContext::create();
+ $this->prepareContext($jmsContext, $context);
+
+ if ($this->serializer instanceof CoreSerializer) {
+ return $this->serializer->toArray($data, $jmsContext);
+ }
+
+ $json = $this->serializer->serialize($data, 'json', $jmsContext);
+
+ return json_decode($json, true, 512, JSON_THROW_ON_ERROR);
+ }
+
+ public function serialize(mixed $data, string $format, array $context = []): string
+ {
+ $jmsContext = SerializationContext::create();
+ $this->prepareContext($jmsContext, $context);
+
+ return $this->serializer->serialize($data, $format, $jmsContext);
+ }
+
+ public function deserialize(mixed $data, string $type, string $format, array $context = []): mixed
+ {
+ $jmsContext = DeserializationContext::create();
+ $this->prepareContext($jmsContext, $context);
+
+ return $this->serializer->deserialize($data, $type, $format, $jmsContext);
+ }
+}
diff --git a/src/Serializer/SerializerInterface.php b/src/Serializer/SerializerInterface.php
new file mode 100644
index 000000000..bbd4683ba
--- /dev/null
+++ b/src/Serializer/SerializerInterface.php
@@ -0,0 +1,17 @@
+ ['triggerWebhook', 1000],
+ ActivityDeleteEvent::class => ['triggerWebhook', 1000],
+ ActivityUpdatePostEvent::class => ['triggerWebhook', 1000],
+
+ CustomerCreatePostEvent::class => ['triggerWebhook', 1000],
+ CustomerDeleteEvent::class => ['triggerWebhook', 1000],
+ CustomerUpdatePostEvent::class => ['triggerWebhook', 1000],
+
+ InvoiceCreatedEvent::class => ['triggerWebhook', 1000],
+ InvoiceDeleteEvent::class => ['triggerWebhook', 1000],
+
+ ProjectCreatePostEvent::class => ['triggerWebhook', 1000],
+ ProjectDeleteEvent::class => ['triggerWebhook', 1000],
+ ProjectUpdatePostEvent::class => ['triggerWebhook', 1000],
+
+ TimesheetCreatePostEvent::class => ['triggerWebhook', 1000],
+ TimesheetStopPostEvent::class => ['triggerWebhook', 1000],
+ TimesheetUpdatePostEvent::class => ['triggerWebhook', 1000],
+
+ UserCreatePostEvent::class => ['triggerWebhook', 1000],
+ UserDeletePostEvent::class => ['triggerWebhook', 1000],
+ UserUpdatePostEvent::class => ['triggerWebhook', 1000],
+
+ TeamCreatePostEvent::class => ['triggerWebhook', 1000],
+ TeamDeleteEvent::class => ['triggerWebhook', 1000],
+ TeamUpdatePostEvent::class => ['triggerWebhook', 1000],
+ ];
+ }
+
+ public function triggerWebhook(Event $event): void
+ {
+ $attribute = $this->findAttribute($event);
+ if ($attribute === null) {
+ return;
+ }
+
+ if (!$this->webhookService->hasWebhook($attribute->name)) {
+ return;
+ }
+
+ $expressionLanguage = new ExpressionLanguage();
+ $parsed = $expressionLanguage->parse($attribute->payload, ['object']);
+ $payload = $parsed->getNodes()->evaluate([], ['object' => $event]);
+
+ $this->webhookService->trigger($attribute->name, $payload);
+ }
+
+ private function findAttribute(Event $event): ?AsWebhook
+ {
+ try {
+ $reflectionClass = new \ReflectionClass($event);
+ $attributes = $reflectionClass->getAttributes(AsWebhook::class);
+ if (\count($attributes) === 1) {
+ $attribute = $attributes[0];
+ $args = $attribute->getArguments();
+
+ return new AsWebhook($args['name'], $args['description'], $args['payload']);
+ }
+ } catch (\Exception $ex) {
+ }
+
+ return null;
+ }
+}
diff --git a/src/Webhook/WebhookService.php b/src/Webhook/WebhookService.php
new file mode 100644
index 000000000..4b1a10e23
--- /dev/null
+++ b/src/Webhook/WebhookService.php
@@ -0,0 +1,58 @@
+serializer->toArray($payload, ['groups' => ['Default', 'Entity', 'Expanded']]);
+
+ foreach ($this->findEventsByName($name) as $event) {
+ $configuration = $event->getConfiguration();
+ $subscriber = new Subscriber($configuration->getUrl(), $configuration->getSecret());
+ $remoteEvent = new RemoteEvent($name, $name . '_' . uniqid(microtime()), $content);
+ $message = new SendWebhookMessage($subscriber, $remoteEvent);
+ $this->bus->dispatch($message);
+ }
+ }
+
+ public function findEventsByName(string $name): array
+ {
+ $url = 'TODO';
+ $secret = 'TODO';
+
+ $configuration = new WebhookConfiguration('n8n', $url, 'json', $secret, 'bearer');
+
+ return [
+ new WebhookEvent($name, $configuration),
+ ];
+ }
+}
diff --git a/symfony.lock b/symfony.lock
index 9f35a6aa5..1a46158ae 100644
--- a/symfony.lock
+++ b/symfony.lock
@@ -467,6 +467,18 @@
"config/packages/mailer.yaml"
]
},
+ "symfony/messenger": {
+ "version": "6.4",
+ "recipe": {
+ "repo": "github.com/symfony/recipes",
+ "branch": "main",
+ "version": "6.0",
+ "ref": "d8936e2e2230637ef97e5eecc0eea074eecae58b"
+ },
+ "files": [
+ "config/packages/messenger.yaml"
+ ]
+ },
"symfony/mime": {
"version": "v4.3.3"
},
@@ -598,6 +610,18 @@
"ref": "6bdfa1a95f6b2e677ab985cd1af2eae35d62e0f6"
}
},
+ "symfony/webhook": {
+ "version": "6.4",
+ "recipe": {
+ "repo": "github.com/symfony/recipes",
+ "branch": "main",
+ "version": "6.3",
+ "ref": "0bc853a74c98e448116ba53e118a7b6b3cdf9a03"
+ },
+ "files": [
+ "config/routes/webhook.yaml"
+ ]
+ },
"symfony/webpack-encore-bundle": {
"version": "1.0",
"recipe": {