Trigger Custom Webhooks with TYPO3's Event Dispatcher (PSR-14)

Modern TYPO3 applications often need to communicate with external services in real-time. Whether you're building headless setups, syncing data with third-party APIs, or triggering automated workflows, webhooks are essential for keeping systems connected.

This guide gives you a better solution: using TYPO3’s modern PSR-14 Event Dispatcher. Instead of using outdated code or custom hacks, you’ll learn how to set up real-time webhook triggers that send clean JSON data to any external service—right when a record is updated in TYPO3.

What is TYPO3 Webhooks?

TYPO3 Webhooks are a way for your TYPO3 website to send real-time data to another system when something changes—like when a page is updated, a new article is published, or a record is deleted.

The TYPO3 content management system (CMS) supports webhooks, which allow your website to automatically send data to another system when something happens—without any manual effort.

Think of a webhook as a digital messenger. When a specific event occurs on your TYPO3 website—like a user submitting a form—a webhook can be triggered to send that information to another system. That system might be:

  • A CRM (like HubSpot or Salesforce),
  • An email service
  • Or even a chat app like Slack or Microsoft Teams.

The webhook sends a message (called a payload) in real time to a specific URL on the external system. This allows TYPO3 to automate tasks, trigger workflows, and integrate with other tools, without needing to constantly check for updates or manually push data.

Prerequisites to Trigger Custom Webhooks

  • TYPO3 v10.4+ (PSR-14 support)
  • Basic PHP knowledge
  • Understanding of TYPO3 Extension development
  • Familiarity with JSON and HTTP requests

Understanding TYPO3's PSR-14 Event Dispatcher

The PSR-14 Event Dispatcher is TYPO3's modern event system that replaced legacy hooks in version 10. It provides a standardized way to listen for and respond to events throughout the TYPO3 core and extensions.

Why Use PSR-14 Over Legacy Hooks?

  • Type Safety: Events are PHP objects with defined properties
  • Better Performance: More efficient than string-based hooks
  • Modern Architecture: Follows PSR-14 standards
  • Future-Proof: The recommended approach for TYPO3 v10+

Setting Up Your Extension

First, let's create the basic structure for our webhook extension.

Directory Structure

ext_webhook/
├── Classes/
│   ├── Event/
│   │   └── WebhookEvent.php
│   ├── EventListener/
│   │   └── WebhookEventListener.php
│   └── Service/
│       └── WebhookService.php
├── Configuration/
│   └── Services.yaml
└── ext_emconf.php

Extension Configuration (ext_emconf.php)

<?php
$EM_CONF['ext_webhook'] = [
    'title' => 'TYPO3 Webhook Integration',
    'description' => 'Send webhooks on record updates using PSR-14',
    'category' => 'misc',
    'version' => '1.0.0',
    'state' => 'stable',
    'author' => 'Your Name',
    'author_email' => 'your.email@example.com',
    'constraints' => [
        'depends' => [
            'typo3' => '10.4.0-11.5.99',
        ],
    ],
];

Creating the Webhook Event

Let's create a custom event that will carry our webhook data.

Classes/Event/WebhookEvent.php

<?php
declare(strict_types=1);

namespace Vendor\ExtWebhook\Event;

final class WebhookEvent
{
    private string $table;
    private int $uid;
    private array $recordData;
    private string $action;
    private string $webhookUrl;

    public function __construct(
        string $table,
        int $uid,
        array $recordData,
        string $action,
        string $webhookUrl
    ) {
        $this->table = $table;
        $this->uid = $uid;
        $this->recordData = $recordData;
        $this->action = $action;
        $this->webhookUrl = $webhookUrl;
    }

    public function getTable(): string
    {
        return $this->table;
    }

    public function getUid(): int
    {
        return $this->uid;
    }

    public function getRecordData(): array
    {
        return $this->recordData;
    }

    public function getAction(): string
    {
        return $this->action;
    }

    public function getWebhookUrl(): string
    {
        return $this->webhookUrl;
    }

    public function getPayload(): array
    {
        return [
            'event' => 'record_' . $this->action,
            'table' => $this->table,
            'uid' => $this->uid,
            'data' => $this->recordData,
            'timestamp' => time(),
        ];
    }
}

Building the Webhook Service

Now let's create a service to handle the actual webhook delivery.

Classes/Service/WebhookService.php

<?php
declare(strict_types=1);

namespace Vendor\ExtWebhook\Service;

use TYPO3\CMS\Core\Http\RequestFactory;
use TYPO3\CMS\Core\Log\LogManager;
use TYPO3\CMS\Core\Utility\GeneralUtility;
use Psr\Log\LoggerInterface;
use Vendor\ExtWebhook\Event\WebhookEvent;

class WebhookService
{
    private RequestFactory $requestFactory;
    private LoggerInterface $logger;

    public function __construct(RequestFactory $requestFactory, LogManager $logManager)
    {
        $this->requestFactory = $requestFactory;
        $this->logger = $logManager->getLogger(__CLASS__);
    }

    public function sendWebhook(WebhookEvent $event): bool
    {
        $url = $event->getWebhookUrl();
        $payload = $event->getPayload();

        try {
            $response = $this->requestFactory->request(
                $url,
                'POST',
                [
                    'headers' => [
                        'Content-Type' => 'application/json',
                        'User-Agent' => 'TYPO3-Webhook/1.0',
                    ],
                    'json' => $payload,
                    'timeout' => 30,
                ]
            );

            $statusCode = $response->getStatusCode();
            
            if ($statusCode >= 200 && $statusCode < 300) {
                $this->logger->info('Webhook sent successfully', [
                    'url' => $url,
                    'table' => $event->getTable(),
                    'uid' => $event->getUid(),
                    'status' => $statusCode,
                ]);
                return true;
            } else {
                $this->logger->warning('Webhook failed with HTTP error', [
                    'url' => $url,
                    'status' => $statusCode,
                    'response' => $response->getBody()->getContents(),
                ]);
                return false;
            }
        } catch (\Exception $e) {
            $this->logger->error('Webhook delivery failed', [
                'url' => $url,
                'error' => $e->getMessage(),
                'payload' => $payload,
            ]);
            return false;
        }
    }

    public function sendWebhookWithRetry(WebhookEvent $event, int $maxRetries = 3): bool
    {
        $attempt = 1;
        
        while ($attempt <= $maxRetries) {
            if ($this->sendWebhook($event)) {
                return true;
            }
            
            if ($attempt < $maxRetries) {
                $delay = pow(2, $attempt); // Exponential backoff
                sleep($delay);
                $this->logger->info("Retrying webhook delivery (attempt $attempt/$maxRetries)");
            }
            
            $attempt++;
        }
        
        $this->logger->error('Webhook delivery failed after all retries', [
            'url' => $event->getWebhookUrl(),
            'attempts' => $maxRetries,
        ]);
        
        return false;
    }
}

Creating the Event Listener

The event listener will respond to TYPO3's built-in events and trigger our webhooks.

Classes/EventListener/WebhookEventListener.php

<?php
declare(strict_types=1);

namespace Vendor\ExtWebhook\EventListener;

use TYPO3\CMS\Core\DataHandling\Event\AfterRecordUpdatedEvent;
use TYPO3\CMS\Core\Configuration\ExtensionConfiguration;
use TYPO3\CMS\Core\Utility\GeneralUtility;
use Vendor\ExtWebhook\Event\WebhookEvent;
use Vendor\ExtWebhook\Service\WebhookService;
use Psr\EventDispatcher\EventDispatcherInterface;

final class WebhookEventListener
{
    private WebhookService $webhookService;
    private EventDispatcherInterface $eventDispatcher;
    private array $configuration;

    public function __construct(
        WebhookService $webhookService,
        EventDispatcherInterface $eventDispatcher
    ) {
        $this->webhookService = $webhookService;
        $this->eventDispatcher = $eventDispatcher;
        
        // Load extension configuration
        $this->configuration = GeneralUtility::makeInstance(ExtensionConfiguration::class)
            ->get('ext_webhook') ?? [];
    }

    public function onAfterRecordUpdated(AfterRecordUpdatedEvent $event): void
    {
        $table = $event->getTable();
        $uid = $event->getUid();
        $recordData = $event->getRecord();

        // Check if webhooks are enabled for this table
        if (!$this->shouldSendWebhook($table)) {
            return;
        }

        // Get webhook URL from configuration
        $webhookUrl = $this->getWebhookUrl($table);
        if (empty($webhookUrl)) {
            return;
        }

        // Create and dispatch webhook event
        $webhookEvent = new WebhookEvent(
            $table,
            $uid,
            $recordData,
            'updated',
            $webhookUrl
        );

        // Send webhook asynchronously (or synchronously based on configuration)
        if ($this->configuration['async_webhooks'] ?? false) {
            // In a real implementation, you'd queue this for background processing
            // For now, we'll send it synchronously
            $this->webhookService->sendWebhookWithRetry($webhookEvent);
        } else {
            $this->webhookService->sendWebhookWithRetry($webhookEvent);
        }
    }

    private function shouldSendWebhook(string $table): bool
    {
        $enabledTables = GeneralUtility::trimExplode(
            ',',
            $this->configuration['enabled_tables'] ?? 'pages,tt_content',
            true
        );

        return in_array($table, $enabledTables, true);
    }

    private function getWebhookUrl(string $table): string
    {
        // You can customize this logic to support multiple URLs per table
        return $this->configuration['webhook_url'] ?? '';
    }
}

Registering Services and Event Listeners

Configure the dependency injection and event listeners.

Configuration/Services.yaml

services:
  _defaults:
    autowire: true
    autoconfigure: true
    public: false

  Vendor\ExtWebhook\:
    resource: '../Classes/*'

  Vendor\ExtWebhook\EventListener\WebhookEventListener:
    tags:
      - name: event.listener
        identifier: 'webhook-after-record-updated'
        method: 'onAfterRecordUpdated'
        event: TYPO3\CMS\Core\DataHandling\Event\AfterRecordUpdatedEvent

Extension Configuration

Add configuration options in ext_conf_template.txt:

# cat=webhook/enable/10; type=string; label=Webhook URL: The URL to send webhooks to
webhook_url = https://example.com/webhook

# cat=webhook/tables/20; type=string; label=Enabled Tables: Comma-separated list of tables to monitor (e.g., pages,tt_content,news)
enabled_tables = pages,tt_content

# cat=webhook/performance/30; type=boolean; label=Async Webhooks: Send webhooks asynchronously (requires queue setup)
async_webhooks = 0

Testing Your Webhook Implementation

1. Install and Configure

  • Install your extension
  • Configure the webhook URL in the Extension Configuration
  • Set up the tables you want to monitor

2. Test Webhook Delivery

Create a simple test endpoint to receive webhooks:

// webhook-test.php
<?php
$json = file_get_contents('php://input');
$data = json_decode($json, true);

file_put_contents('webhook.log', date('Y-m-d H:i:s') . ": " . $json . "\n", FILE_APPEND);

http_response_code(200);
echo "OK";

3. Verify in TYPO3

  • Edit a page or content element
  • Check your webhook endpoint logs
  • Verify the JSON payload structure

Advanced Features

Multiple Webhook URLs

Extend the configuration to support different URLs per table:

private function getWebhookUrl(string $table): string
{
    $urls = [
        'pages' => $this->configuration['webhook_url_pages'] ?? '',
        'tt_content' => $this->configuration['webhook_url_content'] ?? '',
        'default' => $this->configuration['webhook_url'] ?? '',
    ];

    return $urls[$table] ?? $urls['default'];
}

Webhook Signatures

Add security by signing your webhooks:

Add security by signing your webhooks:
private function signPayload(array $payload, string $secret): string
{
    return hash_hmac('sha256', json_encode($payload), $secret);
}

Queue Integration

For high-traffic sites, integrate with TYPO3's queue system:

use TYPO3\CMS\Core\Messaging\AbstractMessage;
use TYPO3\CMS\Core\Messaging\FlashMessage;
use TYPO3\CMS\Core\Messaging\FlashMessageService;

// In your event listener
$this->queueService->add('webhook_queue', $webhookEvent);

Troubleshooting Common Issues

Webhooks Not Firing

  • Check Event Registration: Verify Services.yaml configuration
  • Table Monitoring: Ensure the table is in enabled_tables
  • URL Configuration: Validate the webhook URL is accessible

Performance Issues

  • Enable Async Processing: Set async_webhooks = 1
  • Implement Queues: Use TYPO3's queue system for high volume
  • Add Timeouts: Configure appropriate HTTP timeouts

Debugging Tips

// Add debug logging
$this->logger->debug('Webhook triggered', [
    'table' => $table,
    'uid' => $uid,
    'url' => $webhookUrl,
]);

Best Practices

  • Error Handling: Always implement retry logic and proper error handling
  • Security: Use HTTPS and consider webhook signatures
  • Performance: Use async processing for high-volume sites
  • Monitoring: Log webhook deliveries for debugging
  • Configuration: Make webhook URLs configurable per environment

Conclusion

TYPO3's PSR-14 Event Dispatcher provides a powerful, modern way to implement webhook functionality. By following this guide, you've learned how to:

  • Create custom events and listeners
  • Build a robust webhook delivery system
  • Handle errors and retries gracefully
  • Configure and test your implementation

This approach replaces legacy hooks with a type-safe, performant solution that's perfect for headless TYPO3 setups and third-party integrations.

Next Steps

  • Explore other PSR-14 events for different triggers
  • Implement queue-based async processing
  • Add webhook signature verification
  • Create a backend module for webhook management

The PSR-14 Event Dispatcher opens up endless possibilities for extending TYPO3's functionality. Start experimenting with different events and build the integrations your projects need!

Post a Comment

×

    Got answer to the question you were looking for?