Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

N8N Bundle Laravel Package

freema/n8n-bundle

A PHP bundle for integrating n8n workflow automation into your application. Configure n8n connections, trigger workflows via webhooks, and exchange data between your app and n8n automations with minimal setup.

View on GitHub
Deep Wiki
Context7

Getting Started

What you're actually installing. freema/n8n-bundle is a Symfony bundle — it ships a DI extension, a Configuration tree, and service classes that wrap HTTP calls to an n8n instance (webhook triggers and/or the n8n public API), plus a callback endpoint that n8n can POST results back to. It is not a Laravel package: there is no service provider, no facade, and bundles are booted by the Symfony kernel. If you're in a Laravel app, you will not get value from composer require; you'll get value from reading its ~6 classes and porting them (see below). Porting is now slightly more work than before, because 2.1.0 makes the callback path stateful (cache pool + service lookup) — you'll need a Laravel equivalent (cache + a container-resolved handler registry) to reproduce it.

Symfony path (the intended use):

composer require freema/n8n-bundle

Flex usually registers it in config/bundles.php. Confirm, then dump the reference to see the real option names and the real config root (2.1.0's release notes refer to n8n.callback.secret, so the root is probably n8n, not the freema_n8n this assessment guessed at earlier — verify, don't copy):

php bin/console config:dump-reference n8n

That command is your first stop. src/DependencyInjection/Configuration.php, src/Service/, and the callback controller are a fifteen-minute read and will tell you more than the docs.

Minimal config (keys illustrative — verify with the dump):

# config/packages/n8n.yaml
n8n:
    base_url: '%env(N8N_BASE_URL)%'
    api_key: '%env(N8N_API_KEY)%'
    callback:
        secret: '%env(N8N_CALLBACK_SECRET)%'   # defaults to kernel.secret
        require_signature: true
    tracking:
        cache_pool: 'cache.app'                # shared pool (Redis) if >1 app server
APP_SECRET=change-me                  # callback signing falls back to this
N8N_BASE_URL=https://n8n.internal.example.com
N8N_API_KEY=eyJ...
N8N_CALLBACK_SECRET=another-random-value

Set APP_SECRET before you upgrade. 2.1.0 signs callbacks with callback.secret, which defaults to kernel.secret. If your app has no framework.secret and you don't set n8n.callback.secret, callback signing is broken/insecure — and with require_signature: true (the default) the endpoint will 401 everything.

First use case to build. The one that pays for itself immediately: a domain event fires an n8n webhook that fans out to Slack/email/CRM.

final class OrderPlacedSubscriber implements EventSubscriberInterface
{
    public function __construct(private readonly N8nClient $n8n) {}

    public function onOrderPlaced(OrderPlaced $event): void
    {
        $this->n8n->trigger('order-confirmation', [
            'event_id' => $event->orderId,   // idempotency key — see Gotchas
            'order'    => $event->toArray(),
        ]);
    }

    public static function getSubscribedEvents(): array
    {
        return [OrderPlaced::class => 'onOrderPlaced'];
    }
}

Second use case, now that it's supported properly: use sendWithCallback() and implement N8nResponseHandlerInterface to consume the workflow's result. This is the pattern 2.1.0 actually fixed — before it, handlers registered in one PHP-FPM worker were invisible to the worker that handled the callback.

final class OrderConfirmationHandler implements N8nResponseHandlerInterface
{
    public function __construct(private readonly OrderRepository $orders) {}

    public function handle(N8nResponse $response): void
    {
        $this->orders->markNotified($response->payload()['order_id']);
    }

    public function getHandlerId(): string
    {
        return 'order-confirmation';
    }
}

Register it as a service (autoconfigured with the n8n.response_handler tag). new OrderConfirmationHandler(...) anywhere in your code will never be found by the callback worker.

Laravel path. Copy the client class into app/Services/N8n/N8nClient.php, replace the injected Symfony HttpClientInterface with Illuminate\Support\Facades\Http, and bind it as a singleton in AppServiceProvider. If you need the callback feature, reproduce it as: a signed, expiring URL (URL::temporarySignedRoute + an HMAC over the request UUID), a Cache::lock/Cache::put record keyed by UUID holding the handler id, and a queued job that resolves the handler from the container by that id. Total effort: a day, not an afternoon, if you want the callback path.

Implementation Patterns

Autowire the client, never the raw HTTP client. The whole point of the bundle is that the constructor signature is the contract. Type-hint the package's client interface in controllers, listeners, and console commands; if you type-hint HttpClientInterface directly you've bypassed the config and will fight two sources of truth for the base URL.

Keep listeners thin; move the call to a queue. n8n webhooks return fast (202) but your HTTP round-trip still blocks the request. In Symfony, dispatch a TriggerWorkflowMessage on Messenger's async transport and let a handler do the POST. In Laravel:

final class TriggerN8nWorkflow implements ShouldQueue
{
    use Queueable;
    public int $tries = 3;
    public array $backoff = [10, 60, 300];

    public function __construct(public string $path, public array $payload) {}

    public function handle(): void
    {
        Http::baseUrl(config('services.n8n.url'))
            ->withToken(config('services.n8n.key'))
            ->timeout(5)
            ->retry(2, 200, throw: false)
            ->post("/webhook/{$this->path}", $this->payload)
            ->throw();
    }
}

Name your workflows in config, not in code. Hardcoding /webhook/abc123-uuid paths across the codebase is the #1 maintenance cost with n8n — the path changes when someone re-imports the workflow. Keep a map:

n8n:
    base_url: '%env(N8N_BASE_URL)%'
    api_key: '%env(N8N_API_KEY)%'
    workflows:
        order-confirmation: 'order-confirmation-prod'
        invoice-sync: 'invoice-sync'

Then trigger('order-confirmation', $payload) resolves the path in one place. If the bundle doesn't ship this, it's ten lines on top of it.

Inbound callbacks (n8n → your app) are now first-class — use them properly. The bundle's callback endpoint is no longer "any POST with a _n8n_bundle.uuid goes through". As of 2.1.0:

  • sendWithCallback() gives n8n a callback URL with an expiry and an HMAC-SHA256 of the request UUID; the same UUID is echoed in the _n8n_bundle block.
  • The endpoint returns 401 without a valid signature and 409 for a callback that was already handled.
  • Invalid callbacks are not logged with their raw body any more — don't build dashboards that assume the body is in the app log.

The operational consequence: your n8n workflow must POST back to the callback_url it receives, not to a URL baked into an env var or a node config. A hard-coded callback URL gets 401. If your workflows currently have a fixed URL, change them before deploying 2.1.0.

The callback path is stateful — plan for it. Pending requests live in tracking.cache_pool (default cache.app) keyed to a handler id, and the handler is resolved via getHandlerId() from services tagged n8n.response_handler. Two implications:

  • Multiple app servers: point tracking.cache_pool at a shared pool (Redis/Memcached). With a filesystem pool and two servers, half your callbacks will 409 or hang waiting for a handler that isn't there.
  • Handler identity is a contract. getHandlerId() must be stable and unique. If you generate it from spl_object_hash or a request-scoped value, the callback worker will never find it.

Sign and verify both directions. Outbound triggers are still unauthenticated webhooks unless you add Header Auth on the n8n node; the new signed-callback machinery only protects the return leg. Sign the outbound leg yourself too (a shared header + HMAC) so the n8n webhook node can reject forged triggers.

Integration tips that matter in practice:

  • Set timeout explicitly (5s is generous). Default Symfony/Laravel timeouts of 30–60s will hold PHP-FPM workers hostage when n8n is down.
  • Log a correlation ID: send X-Correlation-Id and put the same value in the payload; n8n's execution log won't show your request headers, so the payload copy is what you'll grep.
  • Use a distinct n8n instance or at minimum a distinct webhook prefix for staging vs prod, and never let a staging deploy point at the prod n8n webhook — that failure mode silently sends real Slack messages.
  • For the public API (/api/v1/workflows, executions), the bundle may expose a separate client; check before assuming the webhook client can list executions.
  • For the callback leg, test with a real signed round-trip (a MockHttpClient won't exercise the signature or the cache). A functional test that hits the route with a generated signature catches the APP_SECRET-unset failure before prod does.

Testing. Symfony: inject MockHttpClient with a queued response and assert the JSON body. For callbacks, build the URL via sendWithCallback(), assert it contains an expires and signature, then replay it against the route with KernelBrowser. Laravel: Http::fake(['*/webhook/*' => Http::response(['ok' => true], 202)]) then Http::assertSent(fn ($r) => $r['event_id'] === $order->id).

Gotchas and Tips

This upgrade is a security fix, not a feature add. GHSA-4p4r-j4mr-f8f4: the callback endpoint previously accepted any POST carrying a _n8n_bundle.uuid, which let anyone reachable dispatch N8nResponseReceivedEvent with arbitrary data and run a pending request's response handler. If you're on 2.0.x with the callback route exposed, treat it as exploitable and upgrade before doing anything else. Don't assume you weren't attacked just because you didn't notice — forged callbacks looked like successful ones.

Upgrade checklist for 2.1.0 (in order):

  1. framework.secret (APP_SECRET) is set, or n8n.callback.secret is — otherwise signing is broken.
  2. Every callback handler is a service implementing N8nResponseHandlerInterface, autoconfigured with the n8n.response_handler tag; nothing is instantiated with new.
  3. Every n8n workflow posts its result to the received callback_url, not a hard-coded one.
  4. n8n.tracking.cache_pool points at a shared pool if you run more than one app server.
  5. n8n.callback.route_path is deprecated and was never used — define the callback route in your routing config per the README, and grep for the key (a config-only line may still emit a deprecation).

This is a five-star, zero-dependent package. Treat it as a well-organized reference implementation rather than hardened infrastructure. 2.1.0 is the first release where that framing softens (it's now had a security review and a real distributed-state fix), but it's still small. Pin the exact version, read src/ before shipping, and assume you may need to fork. That's cheap here — the surface area is tiny — but don't discover it during an incident.

Webhook auth is not what you think. The n8n public API uses X-N8N-API-KEY. Webhook trigger nodes are unauthenticated by default — anyone with the URL can fire your production workflow. The bundle's api_key config almost certainly applies to the API client, not to /webhook/* calls. And the new signature checking protects n8n→app callbacks, not app→n8n triggers. Secure webhooks with the node's Basic Auth / Header Auth credential, or put n8n behind a private network / authenticated reverse proxy.

/webhook/ vs /webhook-test/. n8n exposes production webhooks at /webhook/<path> only when the workflow is Active; before that you get a 404. The test URL is /webhook-test/<path> and only listens while you have "Listen for test event" open in the editor — it will 404 the instant you click away. This single confusion accounts for most "the bundle doesn't work" reports. A 404 from n8n means inactive workflow or wrong path, not a broken client.

Design for fire-and-forget — but now you have a real callback, so use it instead of polling. A webhook trigger returns immediately; the workflow runs asynchronously. You will not get a result body unless the workflow ends in a "Respond to Webhook" node. The correct confirmation model is the signed callback (sendWithCallback() + a handler), which now works across workers. Do not synchronously await workflow output in a request cycle, and do not "solve" it with polling the executions API.

Retries are not idempotent — on either leg. If your HTTP client retries a timed-out webhook, n8n may have already executed it. Always include a stable event_id and dedupe inside n8n (Redis or Postgres node, or a "Remove Duplicates" node keyed on it). On the return leg, the bundle now returns 409 for an already-handled callback — treat 409 from n8n's side as success and make sure your n8n workflow doesn't retry a 409 forever. Without both halves you'll either send duplicate invoices or spin a retry loop.

Status-code triage: 404 → workflow inactive or wrong path; 401/403 → wrong API key or missing webhook credential or (new) a callback signature that didn't validate or a callback whose secret changed; 409 → callback already handled (usually a retry, occasionally a second app server with an unshared cache pool); 422 → n8n received it but a node rejected the payload shape; 500 → a node inside the workflow threw (check the n8n execution log, not your app log). Always log the response body on non-2xx — n8n returns useful error JSON. Note you no longer get the raw body of invalid callbacks in the bundle's log by design; log at your reverse proxy if you need it.

Extension point instead of a fork. Decorate the client rather than editing vendor code:

# config/services.yaml
App\N8n\LoggingN8nClient:
    decorates: 'n8n.client'   # verify the actual service id via `debug:container`
    arguments: ['@.inner', '@logger']

Wrap trigger() with timing and structured logging, or add metrics, and you keep the ability to composer update. Same trick applies to handlers: decorate a handler if you want to log before/after without touching the class that owns the business logic.

Verify the release metadata yourself. An earlier listing claimed a last release of 2026-10-01 — a future-dated tag, usually a mis-set date or a scraped artifact. Confirm 2.1.0 against Packagist and the git tags, and confirm the security advisory link resolves to this repo, before you write it into an upgrade plan.

Version-couple to n8n, not just to the bundle. n8n's webhook path conventions and API surface have shifted across majors, and the callback contract now depends on the n8n side actually posting back to a dynamic URL. Whatever the bundle does is only as current as its last commit; test the full round-trip — trigger, workflow, signed callback, handler — against your actual n8n instance version in CI, not just against a mock.

Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
socialdept/tenant-domains
tautvydasr/blade-class-names
socialdept/color-scales
maherelgamil/controller
coleus/widgets
coleus/table
coleus/users
coleus/music
coleus/health
coleus/calendar
coleus/notes
coleus/support
coleus/settings
coleus/apps
seampact/laravel
emmyconceptng/laravel-ng-postcode
jjsquady/round-robin
skyraptor/chatter
edlugz/sasapay
abbasudo/artisan-route