hasura-extra/hasura-bundle
A Symfony bundle for integrating the Hasura GraphQL engine into PHP applications. It provides configuration, service wiring, and convenience tooling so you can connect to Hasura, manage settings, and query its APIs more easily. Simplify setup and streamline GraphQL workflows from your Symfony projec...
Reality check before you composer require anything: this is a Symfony bundle, not a Laravel package. There is no bundle auto-registration, no config/packages/*.yaml, and no autowiring of HasuraClient into your controllers. The bundle is also archived (last release October 2021, zero dependents), so treat it as a reference implementation rather than a dependency you adopt.
What you actually want from hasura-extra/* in a Laravel app is the framework-agnostic layer underneath it:
hasura-extra/hasura-client — PSR-18/PSR-17 client with a fluent query builder and typed hydration for the GraphQL endpoint.hasura-extra/hasura-event-dispatcher — maps Hasura event-trigger payloads onto PSR-14 events.hasura-extra/hasura-bundle — only the Symfony glue: DI wiring, config tree, action/event controller helpers.Where to look first: the bundle's DependencyInjection/Configuration.php (it is the only authoritative list of config keys), then the client package README for the query API. The bundle's Tests/ fixtures are the fastest way to see the intended end-to-end shape.
First use case in a Laravel app — a thin client bound in the container:
// config/services.php
'hasura' => [
'url' => env('HASURA_URL', 'http://hasura:8080/v1/graphql'),
'admin_secret' => env('HASURA_ADMIN_SECRET'),
],
// app/Services/HasuraClient.php
final class HasuraClient
{
public function __construct(private array $config) {}
public function query(string $gql, array $vars = [], ?string $role = null): array
{
$res = Http::withHeaders(array_filter([
'X-Hasura-Admin-Secret' => $this->config['admin_secret'],
'X-Hasura-Role' => $role,
]))
->timeout(10)
->post($this->config['url'], ['query' => $gql, 'variables' => $vars]);
$body = $res->json() ?? [];
if (! empty($body['errors'])) {
throw new HasuraException($body['errors']);
}
return $body['data'] ?? [];
}
}
// AppServiceProvider::register()
$this->app->singleton(HasuraClient::class,
fn ($app) => new HasuraClient($app['config']['services.hasura']));
That is roughly what the bundle gives you in Symfony, minus the ceremony. If you are deploying the Symfony side, the install is composer require hasura-extra/hasura-bundle, register HasuraExtra\HasuraBundle\HasuraBundle::class => ['all' => true] in config/bundles.php, and configure hasura.endpoint / hasura.admin_secret from env — confirm the exact key names against Configuration.php for the pinned version, they changed between 1.x and 2.x.
Raw query + variables, never interpolation. Hasura is a single POST endpoint; the shape is always {"query": ..., "variables": {...}}. Build a small set of named DTO-ish methods on your client rather than scattering GraphQL strings through controllers, and hydrate results into arrays or spatie/laravel-data objects yourself — the client does not know your schema.
Action handlers (Hasura → your app). An Action is a webhook: Hasura POSTs {"action": {"name": ...}, "input": {...}, "session_variables": {...}} and expects a JSON body matching your action's output type. In Laravel this is a plain route:
Route::post('/hasura/actions/create-invoice', CreateInvoiceAction::class)
->middleware('hasura.action');
Guard it with a shared secret — Hasura cannot sign requests, so you configure a header in the action metadata and compare in middleware:
$given = $request->header('X-Action-Secret', '');
abort_unless(hash_equals(config('services.hasura.action_secret'), $given), 401);
Then read $request->input('input') as your typed argument object, and return the output shape literally (['id' => ..., 'status' => 'created']). The bundle's equivalent is an annotated controller plus its own auth listener; the semantics are identical.
Event triggers. Payload is {"event": {"op", "data": {"new", "old"}}, "table": {...}, "delivery_info": {"id", "current_retry", "max_retries"}}. The pattern that survives contact with production: return 200 immediately, push a job, process asynchronously.
public function __invoke(Request $request)
{
$p = $request->validate([
'event.op' => 'required|string',
'event.data.new' => 'required|array',
'delivery_info.id' => 'required|string',
]);
ProcessOrder::dispatch($p['event']['data']['new'], $p['delivery_info']['id']);
return response()->json(['ok' => true]);
}
Use delivery_info.id as your idempotency key (Cache::add("hasura:evt:{$id}", true, now()->addDay())). Hasura retries non-2xx with backoff, so a non-idempotent handler will double-charge someone eventually.
Role switching. With the admin secret you bypass every permission rule. For anything user-facing, drop the admin secret and send X-Hasura-Role plus X-Hasura-User-Id instead, or use admin_secret only for system jobs and pass an explicit role for the rest. Mirror this with a Hasura::asRole('user', $id) scope on your client so it is impossible to forget.
Testing. Http::fake(['*/v1/graphql' => Http::response(['data' => ['orders' => []]])]), then Http::assertSent(fn ($r) => $r['query'] === ...) — assert variables and headers, not the query string. Same for action endpoints: $this->postJson('/hasura/actions/create-invoice', [...])->assertExactJson([...]).
Config caching. Both Symfony's cache:clear and Laravel's config:cache will freeze your Hasura URL. Wire HASURA_* env vars into config/services.php only, never env() outside config files.
{"errors": [...]}. Every call site must inspect errors — put that in one place or you will silently get null data for weeks.psr/http-client ^1.1 / symfony/* v6+; run composer why-not hasura-extra/hasura-bundle <version> before committing to it. No security patches are coming. MIT license means fork-and-vendor is legitimate if you really want the query builder.Http::withHeaders(...) payloads in a debug middleware, redact X-Hasura-Admin-Secret explicitly.HasuraClient::query() calls, that is three HTTP round trips; compose them into one GraphQL document with aliases or accept the latency knowingly.localhost:8000 means the Hasura container, not your laptop — use the service name or host.docker.internal.variables, then check the x-hasura-* headers your app actually sent. Nine times out of ten the bug is a role header, not the GraphQL.HasuraClient for tracing/logging, (2) swapping the PSR-18 implementation (Guzzle vs Symfony HttpClient) if you adopt the client package, (3) writing your own action/event middleware — the bundle's are small and easier to reimplement in Laravel than to port.How can I help you explore Laravel packages today?