Coming from Laravel
Laravel 13.x concepts mapped to effx annotations and the Effect constructs underneath, with the gaps stated and one endpoint ported side by side.
effx gives a Laravel team a declarative surface it recognises: routes, request validation, policies, exception rendering, generated artifacts. The compiler reads that surface ahead of time and writes ordinary, inspectable Effect. You keep Effect's typed errors and requirements, and you can stop running the compiler and keep the output (ADR 0012, Generated code).
effx is not Laravel for TypeScript. It does not ship a container, an ORM, a queue, an authorization engine or a scaffolder. Where Laravel has a first-party package for something, this page names the Effect package that covers it, or says gap.
Version pin. Laravel claims on this page cite the Laravel 13.x documentation. The documentation index at laravel.com/docs listed 13.x as current on 2026-10-04. Links of the form laravel.com/docs/13.x/<page> redirect to laravel.com/framework/docs/13.x/<page>. Laravel's own first-party packages (Sanctum, Passport, Reverb, Telescope, Pulse) are documented on the same site.
effx claims cite a spec (spec 0005 is docs/specs/0005-http-contract-extension.md) or a source path. Effect claims cite the Effect v4 repository (packages/effect/src/..., ai-docs/src/...).
| Status | Meaning |
|---|---|
| supported | implemented in effx; the row cites a spec or source path |
| via ecosystem package | an Effect package does it; effx does not reimplement it |
| gap | nothing today; not called planned unless a spec says so |
Concept map
| Laravel concept (13.x docs) | effx annotation or construct | Underlying Effect construct | Status | Source |
|---|---|---|---|---|
Controllers and routing: Routing, Controllers. Route::patch(...), make:controller, routes/api.php | @Query or @Command plus @Http.Get, @Http.Post, @Http.Put, @Http.Patch, @Http.Delete; @Http.Group | HttpApiEndpoint, HttpApiGroup, HttpApi, HttpApiBuilder.group, HttpRouter.serve (all emitted into http.ts) | supported | packages/runtime/src/decorators.ts; packages/compiler/src/generate/http.ts; spec 0005; HTTP |
Form requests and validation: Validation. FormRequest::rules(); a failed JSON request returns 422 with an error map | input schema on @Query or @Command; @Http.Contract channels params, query, headers, payload | Schema with filters such as Schema.isMaxLength and Schema.isPattern. The endpoint decodes before the handler runs. A decode failure is HttpApiSchemaError, rendered as an empty 400 unless you transform it with HttpApiMiddleware.layerSchemaErrorTransform | supported | examples/users/src/schemas.ts; packages/compiler/src/generate/http.ts; spec 0005; packages/effect/src/http-api/HttpApiError.ts |
Service container and providers: Service Container, Service Providers. Type-hint autowiring, bind, singleton, bootstrap/providers.php | @Requirements(Service) asserts the handler's inferred R. There is no container and no autowiring (ADR 0012 rules out hidden DI) | Context.Service, Layer, Effect.provide. R stays in the handler's type | supported (the assertion); wiring is a plain Layer that the application owns | ADR 0005; spec 0002 (E/R inference); EFFX2302, EFFX2303; examples/users/src/services.ts; examples/users/src/server-main.ts |
Middleware: Middleware. make:middleware, global stack, groups, ->middleware() | @Http.Contract({ middleware: [Marker] }); group defaults.middleware | HttpApiMiddleware.Service marker, attached as .middleware(Marker). The implementation is a Layer the application writes. effx never instantiates a marker | supported (declaration only) | spec 0005 (middleware); spec 0013; packages/compiler/src/generate/http.ts; Http.Contract |
Authentication guards and first-party Sanctum and Passport: Authentication, Sanctum, Passport. auth:sanctum; Passport is an OAuth2 server | @Http.Contract({ middleware: [SecurityMarker] }) plus @Http.Access({ acceptedCredentials }). EFFX2503 requires a security marker on a protected operation | HttpApiMiddleware.Service with security: { bearer: HttpApiSecurity.bearer } (also apiKey, basic). The application's Layer validates the credential | via ecosystem package (effect/http-api) for the scheme and middleware. Gap for token issuing, abilities, revocation and an OAuth2 server | spec 0006; packages/effect/src/http-api/HttpApiSecurity.ts; ai-docs/src/51_http-server/fixtures/{api,server}/Authorization.ts. A search of the Effect clone's packages/ found no token-issuing or OAuth module |
Gates and policies: Authorization. Gate::define, make:policy, Gate::authorize, the can middleware | @Http.Access({ capabilities, requirements, canonicalScopeResolver, concealment, decisionTime }); @Authorize(Capability.make(...)). The handler receives a required authorize thunk | An application Effect that the handler calls. Http.Access becomes annotateMerge(annotator(...)) on the endpoint, using your annotator's Context | supported (declaration and typed guard binding). Gap for evaluation: effx never decides access | spec 0006; ADR 0007 (Cedar, deferred); packages/compiler/src/generate/{http,guards}.ts; Problems and access |
Exception handling and JSON error responses: Error Handling. withExceptions, render, abort(). A missing bound model gives 404 | @Errors(...) asserts E; @Http.Problems({ registry, codes, map }) | The typed error channel E; the error option of HttpApiEndpoint; the httpApiStatus annotation (an error schema without one is encoded as 500) | supported (declaration and typed E). The registry, the response body and ProblemBoundaryLive are application code | spec 0005 (Problem responses); packages/compiler/src/extensions/problem-contract.ts; packages/effect/src/http-api/HttpApiSchema.ts (getStatusError) |
API resources: Eloquent: API Resources. make:resource, JsonResource::toArray. The outermost resource is wrapped in data by default | success schema; @PersistentModel({ views }) for named views such as User.Public and User.Self. The handler builds the object. There is no transformer class | Schema struct, encoded by the endpoint's success schema | supported | examples/users/src/{user,operations}.ts; packages/compiler/src/extensions/core.ts |
| OpenAPI. Laravel has no first-party OpenAPI feature: the documentation index lists no OpenAPI page | @Http.Contract({ metadata: { operationId, summary, description, tags } }) projects into OpenApi.annotations. effx writes no OpenAPI document | OpenApi.fromApi; HttpApiBuilder.layer(Api, { openapiPath }); HttpApiScalar.layer; HttpApiSwagger.layer | via ecosystem package (effect/http-api) | spec 0005 (Generation: "does not generate an OpenAPI document"); spec 0010; packages/effect/src/http-api/OpenApi.ts; ai-docs/src/51_http-server/10_basics.ts |
| Client generation. Laravel has no first-party client generator: the documentation index lists none | effx build writes client.ts: Client.layerAt(url), one function per operation (for example UserChangeEmail), operationIndex, typed declared failures | HttpApiClient.make and HttpApiClient.ForApi | supported | spec 0007; packages/compiler/src/generate/client.ts; examples/users/.effx/generated/client.ts |
Configuration: Configuration. .env, config/*.php, env() | none. ProjectConfig configures the compiler, not your application | Config, ConfigProvider (the example reads Config.Number("PORT")) | via ecosystem package (effect) | examples/users/src/server-main.ts; packages/compiler/src/Collected.ts (ProjectConfig); packages/effect/src/Config.ts |
Eloquent ORM: Eloquent. make:model, query builder, models as table classes | @PersistentModel({ table, views, focus }) records Model and Focus facts only. No SQL is generated | SqlClient, SqlSchema, SqlModel from effect/sql; drivers such as @effect/sql-pg | via ecosystem package (effect/sql). Gap for effx SQL generation: spec 0008 is proposed, not frozen | spec 0008; STATE.md (Persistence is parked); Persistence and authority; packages/effect/src/sql/; packages/sql/pg |
Migrations: Migrations. make:migration, migrate | none | Migrator from effect/sql | via ecosystem package (effect/sql). Gap for deriving migrations from models | packages/effect/src/sql/Migrator.ts; spec 0008 (proposed only) |
Queues, jobs and scheduling: Queues, Task Scheduling. make:job, ShouldQueue, Schedule:: | none. No job or schedule annotation exists in @effx/runtime or the compiler | PersistedQueue (effect/persistence); Workflow, DurableQueue (effect/workflow); ClusterCron (effect/cluster); Schedule, Cron | via ecosystem package. Gap for any effx annotation | packages/runtime/src, packages/compiler/src/extensions (no match for queue, schedule or cron); packages/effect/src/persistence/PersistedQueue.ts; packages/effect/src/workflow/; packages/effect/src/cluster/ClusterCron.ts |
Caching: Cache. Cache:: drivers | none. Http.Contract.conditional adds a 304 for a GET, and the handler owns ETag logic. It is HTTP conditional logic, not an application cache | Cache, ScopedCache; PersistedCache (effect/persistence) | via ecosystem package. Gap for any effx annotation | spec 0005 (conditional); packages/effect/src/Cache.ts; packages/effect/src/persistence/PersistedCache.ts |
| Realtime: Broadcasting, Reverb (a WebSocket server) | none. The generated RPC server uses the HTTP protocol only | RpcServer.layerProtocolWebsocket (effect/rpc); Socket (effect/socket); Sse (effect/encoding) | via ecosystem package. Gap for any effx annotation | examples/users/.effx/generated/http.ts (protocol: "http"); packages/effect/src/rpc/RpcServer.ts; packages/effect/src/socket/Socket.ts; packages/effect/src/encoding/Sse.ts |
| Testing: HTTP Tests, Testing. Pest or PHPUnit | none. effx generates OperationsClient.layerTest for the RPC client. It ships no test harness | @effect/vitest; HttpApiTest.groups; RpcTest | via ecosystem package. Gap for an effx harness | packages/compiler/src/generate/rpc.ts (layerTest); examples/users/test/e2e.test.ts; packages/vitest; ai-docs/src/51_http-server/20_testing.ts |
CLI and scaffolding: Artisan. Custom commands, make:*, stub:publish | @Cli("users change-email") generates a command. make:* scaffolding has no equivalent | Command and Flag from effect/cli | supported for generated commands. Gap for make:* scaffolding: the effx CLI has only check, build, inspect, graph | packages/compiler/src/generate/cli.ts; examples/users/.effx/generated/cli.ts; scripts/effx.ts; spec 0003 |
| Observability: Logging (Monolog), Telescope, Pulse. Nightwatch appears in Error Handling as a reporting destination and has no page of its own in the 13.x index | none. The generators emit no log calls and no spans | Effect.log, Logger; Otlp (effect/observability); @effect/opentelemetry | via ecosystem package | packages/compiler/src/generate/ (no match for Effect.log, withSpan, Tracer); packages/effect/src/observability/Otlp.ts; ai-docs/src/08_observability/ |
Gaps today
These are things a Laravel team will look for and will not find in effx. None is called planned. Where a spec exists, it is cited.
- SQL persistence and migrations.
@PersistentModelrecords facts. Spec 0008 (SqlProjection) is proposed, not frozen, andSTATE.mdparks it.examples/userskeeps users in an in-memoryRef<HashMap>(examples/users/src/services.ts). Useeffect/sqldirectly. - Authorization evaluation. effx records the access declaration and requires a typed guard binding. It never resolves a credential, interprets a policy or chooses a principal (spec 0006). Cedar and leases are deferred past the MVP kernel (ADR 0007). A required binding is not proof the handler called it. The real security gate is the application's own journey.
- Authentication products. Nothing in effx or in the Effect clone issues tokens, manages abilities or revocation, or runs an OAuth2 server. Effect supplies the security scheme and middleware slot (
HttpApiSecurity,HttpApiMiddleware), not a Sanctum or Passport. - Jobs, queues, scheduling, caching, realtime. No annotation. The Effect packages in the table cover the runtime side.
- Scaffolding and generators. No
make:*. TheeffxCLI compiles and inspects; it does not write source. - Testing harness. No effx test helpers beyond the generated RPC
layerTest. - Implicit route model binding. There is no equivalent. The handler or a service loads the entity and fails with a typed error such as
UserNotFound(examples/users/src/services.ts). - Problem Details builder. effx ships no RFC 9457 envelope.
Http.Problemscalls a registry function that your application exports. Domain-error to problem conversion andProblemBoundaryLiveare hand-written (spec 0005). - Validation error shape. There is no 422 error map by default. A failed decode is an empty 400 (
HttpApiSchemaError) until you transform it. - Application-wide middleware. There is no annotation. Middleware attaches per operation or through a group default. For app-wide behaviour use raw Effect (
effect/http), which can coexist in the same program (ADR 0012). - Maturity. Evidence for access, problems and the client is compiler and fixture level. The mono-web migration gates in
STATE.mdare separate and several are unlanded. Treat this page's port as a design translation, not a production claim.
Port one endpoint
The endpoint is PATCH /users/{id}/email, for an authenticated user:
- The body is
{ "email": "..." }with a format rule and a length limit. - Only the user themselves may change their email. This rule is invented for the page. The effx example does not state who may change an email.
- An unknown user returns 404 and an email held by another user returns 409, both as JSON problem-style bodies.
All code below that is not an include is pasted and illustrative. It was not compiled, generated or run for this page. The effx snippets follow fixtures that the repository tests (named under each block).
Route. routes/api.php is created by install:api. Its routes are stateless, belong to the api middleware group and get the /api prefix, so the live path is /api/users/{user}/email. auth:sanctum protects a route. (Routing, Sanctum)
use App\Http\Controllers\UserEmailController;
use Illuminate\Support\Facades\Route;
Route::patch('/users/{user}/email', [UserEmailController::class, 'update'])
->middleware('auth:sanctum');Form request. A form request has authorize and rules. It is validated before the controller method runs. If authorize returns false, Laravel returns 403 and the controller does not run. A failed JSON request returns 422 with the errors. The route method reads the URI parameters. (Validation: form requests, Authorizing form requests)
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class ChangeEmailRequest extends FormRequest
{
public function authorize(): bool
{
// The policy check. `user` is the bound User model from the route.
return $this->user()->can('updateEmail', $this->route('user'));
}
/** @return array<string, array<int, string>> */
public function rules(): array
{
return [
'email' => ['required', 'string', 'email', 'max:255'],
];
}
}Policy. Laravel finds UserPolicy for the User model by naming convention when it sits in a Policies directory at or above the models. Policy method names are free. make:policy generates the class. (Authorization: creating policies, writing policies)
namespace App\Policies;
use App\Models\User;
class UserPolicy
{
public function updateEmail(User $actor, User $target): bool
{
return $actor->id === $target->id;
}
}Controller, service and resource. The controller receives its dependencies by type-hint. With implicit binding, a missing User produces a 404 before the controller runs. A resource class shapes the JSON, and by default the outermost resource is wrapped in a data key. (Controllers, Routing: implicit binding, Service container, API resources)
namespace App\Http\Controllers;
use App\Http\Requests\ChangeEmailRequest;
use App\Http\Resources\UserSelfResource;
use App\Models\User;
use App\Services\UserDirectory;
class UserEmailController extends Controller
{
public function update(ChangeEmailRequest $request, User $user, UserDirectory $users): UserSelfResource
{
return new UserSelfResource(
$users->changeEmail($user, $request->validated()['email']),
);
}
}namespace App\Services;
use App\Exceptions\EmailAlreadyTaken;
use App\Models\User;
class UserDirectory
{
public function changeEmail(User $user, string $email): User
{
$other = User::where('email', $email)->where('id', '!=', $user->id)->first();
if ($other !== null) {
throw new EmailAlreadyTaken($email);
}
$user->email = $email;
$user->save();
return $user;
}
}(Eloquent: retrieving and updating models for where, first and save.)
namespace App\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class UserSelfResource extends JsonResource
{
/** @return array<string, mixed> */
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'email' => $this->email,
'displayName' => $this->display_name,
];
}
}Exception rendering. bootstrap/app.php registers render closures through withExceptions. The closure may return response()->json([...], 404), and the docs show overriding NotFoundHttpException for api/* requests. The response object has a header method for setting Content-Type. The Laravel documentation does not describe RFC 9457 Problem Details, so the problem-style body below is application-defined. (Error Handling: rendering exceptions, Responses: JSON, Responses)
namespace App\Exceptions;
use Exception;
class EmailAlreadyTaken extends Exception
{
public function __construct(public readonly string $email)
{
parent::__construct("The email {$email} belongs to another user.");
}
}use App\Exceptions\EmailAlreadyTaken;
use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Http\Request;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
->withExceptions(function (Exceptions $exceptions): void {
$exceptions->render(function (EmailAlreadyTaken $e, Request $request) {
return response()->json([
'type' => 'https://example.com/problems/email-taken',
'title' => 'Email already in use',
'status' => 409,
'detail' => $e->getMessage(),
], 409)->header('Content-Type', 'application/problem+json');
});
$exceptions->render(function (NotFoundHttpException $e, Request $request) {
if ($request->is('api/*')) {
return response()->json([
'type' => 'https://example.com/problems/user-not-found',
'title' => 'User not found',
'status' => 404,
], 404)->header('Content-Type', 'application/problem+json');
}
});
})What Laravel does without extra code on this route, per the cited pages: a denied form-request authorize gives 403; a failed validation on a JSON request gives 422 with errors; a missing bound model gives 404.
How the layers line up
| Layer | Laravel | effx declaration | Effect underneath |
|---|---|---|---|
| Route | Route::patch('/users/{user}/email', ...) | @Http.Patch("/users/:id/email") | HttpApiEndpoint.patch("changeEmail", "/users/:id/email", { ... }) |
| Authentication | ->middleware('auth:sanctum') | @Http.Contract({ middleware: [PersonSecurity] }) plus acceptedCredentials | .middleware(PersonSecurity) with HttpApiSecurity.bearer |
| Validation | ChangeEmailRequest::rules() | ChangeEmailBody and ChangeEmailParams schemas in @Http.Contract | params and payload decoded by the endpoint before the handler |
| Policy | ChangeEmailRequest::authorize() calling UserPolicy::updateEmail | @Http.Access({ ... }), the authorize thunk and the handler's canChangeEmail check | guards["changeEmail"](request) and a plain function |
| Service | UserDirectory injected by the container | @Requirements(Users) | Context.Service and Layer (examples/users/src/services.ts) |
| Not found, conflict | ModelNotFoundException as 404; EmailAlreadyTaken rendered as 409 | @Errors(...) and @Http.Problems({ registry, codes, map }) | typed E; error: with httpApiStatus 404, 409, 403 |
| Response | UserSelfResource | success: User.Self | the success schema encodes the result |
Source for the effx column: spec 0005, spec 0006, packages/compiler/src/generate/{http,guards}.ts, docs/specs/0006-access-contract-extension.md §"HTTP lowering and guard interface".
What the port preserves
- The route, the method and a declared bearer-protected contract.
- Validation of params and body at the boundary, before handler code runs. Params and body are decoded in
HttpApiBuilderbefore the handler is called (packages/effect/src/http-api/HttpApiBuilder.ts). - Three outcomes with fixed statuses: 404, 409 and 403. Unlike Laravel's
renderclosures, each is a typed member of the operation's error channel, so the generated client (DeclaredFailuresinclient.ts) and the OpenAPI document see them. - The service's typed failures.
examples/users/test/e2e.test.tsassertsEmailTakenthrough the generated RPC client.
What the port does not preserve
- Access evaluation stays application code. effx records the
Http.Accessdeclaration and requires a typedauthorizebinding. It does not run the policy, resolve the token or pick the principal (spec 0006). A required binding is not proof that the handler called it, and the real gate is the application's own journey. Cedar and leases are deferred (ADR 0007). In Laravel the framework callsFormRequest::authorize()for you before the controller. Here the handler must callauthorize()itself. - Problem responses follow spec 0005, not RFC 9457.
Http.Problemsimports your registry and emitserror: Registry(...). The registry above only attaches statuses to the existing error classes, so the 409 body is the encoded form of theEmailTakenschema, not thetype,title,status,detailbody of the PHP version. To emit RFC 9457 bodies the application writes that derivation, as spec 0005 describes for mono-web. effx has no Problem Details builder, andProblemBoundaryLiveis hand-written. - Validation failures differ. Laravel returns 422 with an error map. A failed decode here is an empty 400 until you transform
HttpApiSchemaError. - No route model binding. Laravel returns 404 before the controller runs. Here the service fails
UserNotFoundinside the handler, after the guard. - Persistence is not the same. The PHP service checks and then saves, so only a unique index stops a concurrent writer. The effx example does both in one atomic
Ref.modifyon an in-memory store that does not survive a restart (STATE.md, Deferred notes). Neither is a port of SQL behaviour. - Response wrapping. Laravel wraps the outermost resource in
databy default. The effx response is the schema-encodedUser.Self. - Dropped exposures. The protected variant is HTTP-only.
@Rpcand@Cliwould need the guard argument too. - Wiring changes.
AppRoutesbecomesAppRoutes(guards). The security marker needs aLayer. - Unverified here. The pasted effx snippets were not compiled or built for this page. Run
effx buildand the TypeScript check on your copy to confirm. If the declaration and the handler disagree, the compiler reportsEFFX2201orEFFX2202(errors),EFFX2205orEFFX2206(problem mapping), orEFFX2503(a protected operation without a security marker).
Where to go next
Coming from ASP.NET Core
A concept map from ASP.NET Core controllers, policies, ProblemDetails and OpenAPI to effx annotations and the Effect constructs underneath, a list of gaps, and one PATCH endpoint ported side by side.
Authoring overview
Decorators and builder chains record the same Annotations. Pick the syntax that fits the declaration.