ghua/ext-direct-bundle
Symfony bundle for integrating Ext.Direct with PHP backends, exposing controllers and services as Ext.Direct APIs so Ext JS front ends can make seamless remote procedure calls with simplified routing and provider configuration.
First, reset expectations. ghua/ext-direct-bundle is a Symfony2 bundle, not a Laravel package. composer require in a Laravel 9+ app will fail on the symfony/* constraints (~2.x vs. the 6/7 components Laravel pulls), and none of the framework glue — the Bundle class, the DI extension, the routing loader, the annotation reader — maps to Laravel. What you actually start with is the protocol layer: roughly 300–600 lines that generate the ExtDirect API descriptor and dispatch batched RPC envelopes. That is the artifact you leverage.
Where to look first, in the repo:
Resources/config/ — the service definitions that register the provider. This tells you what the bundle wires.Configuration.php / DI extension — shows the config shape (namespace, action mappings, JS output path).{ id, type: 'remoting', url, actions: {...} } blob the Ext JS client bootstraps from. This is the part you want to preserve byte-for-byte in shape.The first thing you should do is capture a working extdirect/api descriptor and a sample extdirect/router request/response from the existing stack (if one still runs) before you write any code. That fixture is your spec. Without it you are reverse-engineering a decade-old bundle against Sencha docs.
Minimum viable setup in Laravel (the direction you'll actually take):
composer require ghua/ext-direct-bundle # read-only reference; do not ship this
Vendor the source into a reference/ directory or fork it. Then stand up two routes in your own app, not via the bundle:
// routes/extdirect.php
Route::get('/extdirect/api', [ExtDirectController::class, 'descriptor']);
Route::post('/extdirect/router', [ExtDirectController::class, 'dispatch'])
->middleware(['api', 'throttle:extdirect', 'extdirect.csrf']);
First use case: a single action (Order::load) returning a fixed payload, verified against an Ext JS RemotingProvider configured to your new url. If the client renders, your envelope is correct and you can grow the action registry from there.
Pattern 1 — Protocol adapter as a first-party package. Build app/ExtDirect/ with four pieces: a MethodRegistry, a DescriptorBuilder, a RequestDispatcher, and a ResponseBuilder. Do not try to port Symfony DI conventions; you are writing a Laravel package that happens to speak ExtDirect. Service-provider registration gives you the binding point the Symfony bundle got from its DI extension.
Pattern 2 — Explicit registry, populated by attributes. Never infer the action surface from the container. The Symfony bundle's annotation-driven mapping is exactly the mistake to avoid; it makes arbitrary-method invocation reachable from an unauthenticated JSON endpoint.
#[ExtDirectMethod(action: 'Order', allow: ['role:admin'])]
public function load(int $id): array { /* ... */ }
At boot, scan a declared directory, populate MethodRegistry, and freeze it. The registry is your allow-list; anything not in it returns an exception envelope.
Pattern 3 — Descriptor generation from the registry. The descriptor is a projection of the registry, not a separate source of truth. Build it lazily and cache it (Cache::rememberForever('extdirect.descriptor', ...)) keyed on a deploy hash. This is the one piece you can lift almost verbatim from the bundle — the shape is stable across Ext JS 4–6.
{
"id": "extdirect",
"type": "remoting",
"url": "/extdirect/router",
"actions": { "Order": [{ "name": "load", "len": 1 }] }
}
Pattern 4 — Two dispatch paths, one envelope. The router has to accept both:
type absent): body is { action, method, data, tid, type }. Batch is an array of these.extType=form): POST with extAction, extMethod, extTID, extType=form, and form fields as the args.Handle these in the controller, normalize both to a (action, method, args, tid) tuple, then run one dispatcher. Keep the normalization step thin and well-tested — this is where drift lives.
Pattern 5 — Batch loop with per-item error isolation. Ext JS sends ordered arrays and expects ordered results; echo tid back per item. Execute each item in isolation so one failure produces one exception envelope in the array, not a 500.
foreach ($calls as $call) {
try {
$result[] = ['type' => 'rpc', 'tid' => $call['tid'], 'action' => $call['action'],
'method' => $call['method'], 'result' => $this->invoke($call)];
} catch (Throwable $e) {
Log::error('extdirect', ['call' => $call, 'exception' => $e]);
$result[] = ['type' => 'exception', 'tid' => $call['tid'], 'action' => $call['action'],
'method' => $call['method'], 'message' => 'Request failed',
'where' => '', 'trace' => ''];
}
}
return response()->json($result);
Pattern 6 — Middleware stack, ordered deliberately. ExtDirect funnels everything through one POST endpoint, so per-method authorization is the only fine-grained control you have. Compose: authentication → CSRF (custom, because Ext JS sends a token header, not a form field) → batch throttle → dispatcher. Put the authz check inside invoke(), not in a route middleware, because per-action policy cannot be expressed at route granularity.
Pattern 7 — Strangler cutover, per action. If you are migrating off a live Symfony2 service, put both behind a reverse proxy that routes by action name. Port one action at a time, capture its fixtures, add a conformance test, cut over. Unported actions return an exception envelope from the Laravel side so the client fails cleanly.
Day-to-day workflow: add an action → annotate with #[ExtDirectMethod] → write a feature test that POSTs the envelope and asserts the response shape → run. The attribute is the only registration step; the descriptor rebuilds from it, so the client sees the new method on the next page load. This is the single biggest ergonomic win over hand-rolling REST — it is why the pattern existed.
HTTP 200 on application errors. Ext JS reads the error out of the envelope, not the status code. A 500 causes the client to treat the whole batch as a transport failure and skip your per-item exception. Always response()->json($result, 200) for anything the dispatcher handled; reserve 4xx/5xx for cases where the envelope itself is malformed.
Batch amplification is a real attack surface. One HTTP request becomes N dispatches. Cap batch size (25 is a reasonable ceiling) and enforce a per-request method budget. Without it, your throttle middleware counts one request while your PHP runs twenty.
Polling is the operational trap. PollingProvider long-polls hold a PHP-FPM worker for the poll duration. Under a small pool or Octane, you exhaust workers before CPU. If the front-end uses it, either replace with SSE/websocket, or isolate it on a dedicated worker pool with a hard max_execution_time. Do not let it share the RPC pool.
Do not leak the trace. The exception envelope has message, where, and trace fields. In production, sanitize all three and return a correlation ID; log the detail server-side. The Symfony bundle did not do this — you must.
The tid echo is not optional. Clients match responses to requests by tid, and batched responses must be ordered to match the request array. Drop the tid or reorder and the client silently drops results. This is the single most common port bug.
Deserialization boundary. Do not pass raw data arrays into reflection-based invocation. Type them against per-method DTOs. The ExtDirect protocol has no schema; you supply one via your method signatures.
CSRF for a single POST endpoint. Laravel's built-in CSRF middleware expects a form field or X-CSRF-TOKEN header — fine for a single call, awkward for form-submit path (extType=form). Exempt the form path explicitly and validate an origin/Referer check there, or accept a token as an extTID-adjacent field.
Descriptor caching vs. registry drift. If you cache the descriptor keyed on a hash, remember that adding an action without invalidating the cache leaves the client blind to it. Tie the cache key to the registry's content hash, not to deploy time.
Symfony2 idioms to strip on sight: annotation readers, CompilerPass registration, Symfony\Component\HttpKernel\Event subscribers, @Route annotations. None survive the port; trying to keep their shape will cost you days. Keep only the envelope logic.
Where to extend vs. rewrite. If you find yourself adding per-method authorization, CSRF handling, a batch cap, and DTO validation — that is four of the five things this bundle never had. At that point the rewrite is cheaper than the fork, and you should stop trying to preserve its structure and write the dispatcher fresh against your fixture corpus.
The strategic read. Archived, 15 stars, 0 dependents, Symfony2-era: this is code you adopt and own, not a dependency you can trust. Fork it for reference, port the protocol into a first-party Laravel package behind a contract test, and if the Ext JS client is yours to change, price the REST migration as the real exit. The ExtDirect protocol is the easy part; the client decision is the one that matters.
How can I help you explore Laravel packages today?