justbetter/laravel-magento-webhooks
justbetter/laravel-magento-webhooks makes it easy to receive and process Magento webhooks in Laravel. Register endpoints, map incoming Magento events to handlers, and integrate webhook data cleanly into your existing Laravel workflows.
composer require justbetter/laravel-magento-webhooks
php artisan vendor:publish --provider="JustBetter\MagentoWebhooks\MagentoWebhooksServiceProvider"
The publish step exposes config/magento-webhooks.php — open that file first. It is the source of truth for the inbound route path, the signing secret, and (usually) the Magento-event → Laravel-event map. Then confirm the route exists before wiring anything in Magento:
php artisan route:list | grep -i webhook
Typical .env wiring:
MAGENTO_WEBHOOKS_SECRET=the-same-secret-you-enter-in-magento
MAGENTO_WEBHOOKS_PATH=webhooks/magento
In Magento admin, enable the Mageplaza Webhooks module (Stores → Configuration → Mageplaza → Webhooks), add a webhook with the full URL of the published route (e.g. https://app.test/webhooks/magento), paste the same secret, and pick the observer events you care about (sales_order_place_after, customer_register_success, catalog_product_save_after, …).
First use case: create a queued listener for the package's dispatch event and dump the payload:
// app/Listeners/DebugMagentoWebhook.php
public function handle($event): void
{
Log::info('magento webhook', ['name' => $event->name, 'payload' => $event->payload]);
}
Register it against whatever the package dispatches (check vendor/justbetter/laravel-magento-webhooks/src/Events for the exact class name), hit Send Test / trigger a real event in Magento, and read the payload. That log line is your starting point for every new integration — build the mapping only after you see what Magento actually sends for each event.
Map Magento events to your own events. The config file usually lets you bind a Magento observer name to an application event class rather than subscribing to the package's generic event. This keeps your listeners decoupled from the package:
// config/magento-webhooks.php
'events' => [
'sales_order_place_after' => \App\Events\OrderPlacedInMagento::class,
// ...
],
Always queue the real work. The webhook endpoint has to answer Magento fast. Keep the listener thin and push anything slow (API calls, DB writes, mail) onto a queue:
class SyncMagentoOrder implements ShouldQueue
{
public $tries = 5;
public $backoff = [10, 60, 300];
public function handle(OrderPlacedInMagento $event): void
{
$order = MagentoOrder::find($event->payload['data']['entity_id']);
// ...
}
}
Treat the payload as a notification, not the record. Mageplaza webhooks are thin — you usually get IDs and a few fields. Fetch the full entity through your Magento client (justbetter/laravel-magento-api fits naturally here) inside the queued listener, not in the controller path.
Be idempotent. Magento will retry on non-2xx and will also fire the same observer multiple times for one logical change. Persist a unique key derived from the event name + entity id (or the webhook id header if present) before processing:
$key = "{$event->name}:{$event->payload['data']['entity_id']}";
if (! ProcessedWebhook::createIfNotExists(['key' => $key])) {
return;
}
Multiple Magento instances / stores. If you listen to more than one Magento install, register separate routes with distinct secrets rather than sharing one. If the package supports named sources in config, use them; otherwise add routes pointing at the package's controller with an extra source parameter and branch in a middleware or listener.
Testing without Magento. Build a helper that signs a payload and posts it:
$payload = json_encode(['event' => 'sales_order_place_after', 'data' => ['entity_id' => 42]]);
$signature = hash_hmac('sha256', $payload, config('magento-webhooks.secret'));
$this->call('POST', '/webhooks/magento', [], [], [], [
'HTTP_X_MAGENTO_SIGNATURE' => $signature,
'CONTENT_TYPE' => 'application/json',
], $payload)->assertOk();
Freeze the Magento side of the integration with Http::fake() and assert your listener dispatched the right job.
Log every inbound call at info level with the event name, entity id, and a status. You will need it the first time a customer says "the order never appeared" — the answer is usually "Magento never sent it" or "the signature was rejected", and both are visible in one grep.
Debugging signature failures. The most common failure mode. Verify (a) the header name — Mageplaza has shipped variations, check vendor/justbetter/laravel-magento-webhooks/src/Http/Middleware; (b) that the HMAC is computed over the raw request body, not json_encode(request()->all()) — Laravel's re-serialization reorders keys and drops formatting; (c) that the .env secret matches Magento exactly, including whitespace and trailing newlines. If the middleware looks fragile, hash_equals over $request->getContent() is the canonical implementation.
CSRF. The route must be registered as stateless. If you re-register the route yourself under the web group, add the path to VerifyCsrfToken::$except. The package's own route should already be on api or without CSRF, but confirm after upgrades.
Config caching. php artisan config:cache after every change to config/magento-webhooks.php or .env — the map is read once at boot. Symptom of forgetting: events arrive and return 200 but nothing happens.
Unknown event names. Magento observer names are literal (sales_order_save_after, not sales_order.saved). If an event is not in your map, the package may silently accept-and-drop or throw 4xx depending on version. Return 2xx for events you intentionally ignore — otherwise Magento will retry forever.
Retries amplify bugs. A listener that throws will get retried by Magento's webhook queue and by Laravel's queue. That's two retry loops on the same payload. Combine idempotency keys with bounded queue retries ($tries, $backoff) and make failures loud — a silently swallowed exception here means orders vanish.
Timeout sensitivity. Mageplaza's dispatch is not free. If your endpoint takes more than a few seconds Magento will consider it failed and retry. Never do HTTP calls or heavy DB work in the controller or in a synchronous listener.
Do not rely on payload shape stability. Mageplaza formats differ between module versions and sometimes between events. Normalize in one small DTO/parser per event type and validate the keys you need — don't scatter $payload['data']['entity_id'] across listeners.
Route caching. php artisan route:cache will drop any closure-based custom routes you added around this package. Move anything you register yourself into a controller.
Rotating the secret. Mageplaza supports one secret per webhook; a naive rotation means a window of 401s. Either accept downtime, or extend the package's middleware to accept a list of secrets (env MAGENTO_WEBHOOKS_SECRETS as comma-separated) and try each with hash_equals.
Extension points worth knowing. The config file, the event map, the middleware, and the controller are all bindable/replaceable through the service container. If the package's behavior is close but not exact — extra fields on the dispatched event, alternative verification — bind your own subclass in a service provider rather than forking:
$this->app->bind(
\JustBetter\MagentoWebhooks\Http\Controllers\WebhookController::class,
\App\Http\Controllers\MagentoWebhookController::class,
);
Small package, thin surface. With ~12 stars and a single maintainer, expect to read src/ when something doesn't behave. It is small enough to audit in 15 minutes, and that is usually faster than waiting on an upstream fix. Pin the version in composer.json and add an integration test that posts a signed payload — it is your cheapest guard against a surprise on upgrade.
How can I help you explore Laravel packages today?