effx
Coming from…

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/...).

StatusMeaning
supportedimplemented in effx; the row cites a spec or source path
via ecosystem packagean Effect package does it; effx does not reimplement it
gapnothing today; not called planned unless a spec says so

Concept map

Laravel concept (13.x docs)effx annotation or constructUnderlying Effect constructStatusSource
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.GroupHttpApiEndpoint, HttpApiGroup, HttpApi, HttpApiBuilder.group, HttpRouter.serve (all emitted into http.ts)supportedpackages/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 mapinput schema on @Query or @Command; @Http.Contract channels params, query, headers, payloadSchema 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.layerSchemaErrorTransformsupportedexamples/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 typesupported (the assertion); wiring is a plain Layer that the application ownsADR 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.middlewareHttpApiMiddleware.Service marker, attached as .middleware(Marker). The implementation is a Layer the application writes. effx never instantiates a markersupported (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 operationHttpApiMiddleware.Service with security: { bearer: HttpApiSecurity.bearer } (also apiKey, basic). The application's Layer validates the credentialvia ecosystem package (effect/http-api) for the scheme and middleware. Gap for token issuing, abilities, revocation and an OAuth2 serverspec 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 thunkAn application Effect that the handler calls. Http.Access becomes annotateMerge(annotator(...)) on the endpoint, using your annotator's Contextsupported (declaration and typed guard binding). Gap for evaluation: effx never decides accessspec 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 codespec 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 defaultsuccess schema; @PersistentModel({ views }) for named views such as User.Public and User.Self. The handler builds the object. There is no transformer classSchema struct, encoded by the endpoint's success schemasupportedexamples/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 documentOpenApi.fromApi; HttpApiBuilder.layer(Api, { openapiPath }); HttpApiScalar.layer; HttpApiSwagger.layervia 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 noneeffx build writes client.ts: Client.layerAt(url), one function per operation (for example UserChangeEmail), operationIndex, typed declared failuresHttpApiClient.make and HttpApiClient.ForApisupportedspec 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 applicationConfig, 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 generatedSqlClient, SqlSchema, SqlModel from effect/sql; drivers such as @effect/sql-pgvia ecosystem package (effect/sql). Gap for effx SQL generation: spec 0008 is proposed, not frozenspec 0008; STATE.md (Persistence is parked); Persistence and authority; packages/effect/src/sql/; packages/sql/pg
Migrations: Migrations. make:migration, migratenoneMigrator from effect/sqlvia ecosystem package (effect/sql). Gap for deriving migrations from modelspackages/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 compilerPersistedQueue (effect/persistence); Workflow, DurableQueue (effect/workflow); ClusterCron (effect/cluster); Schedule, Cronvia ecosystem package. Gap for any effx annotationpackages/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:: driversnone. Http.Contract.conditional adds a 304 for a GET, and the handler owns ETag logic. It is HTTP conditional logic, not an application cacheCache, ScopedCache; PersistedCache (effect/persistence)via ecosystem package. Gap for any effx annotationspec 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 onlyRpcServer.layerProtocolWebsocket (effect/rpc); Socket (effect/socket); Sse (effect/encoding)via ecosystem package. Gap for any effx annotationexamples/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 PHPUnitnone. effx generates OperationsClient.layerTest for the RPC client. It ships no test harness@effect/vitest; HttpApiTest.groups; RpcTestvia ecosystem package. Gap for an effx harnesspackages/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 equivalentCommand and Flag from effect/clisupported for generated commands. Gap for make:* scaffolding: the effx CLI has only check, build, inspect, graphpackages/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 indexnone. The generators emit no log calls and no spansEffect.log, Logger; Otlp (effect/observability); @effect/opentelemetryvia ecosystem packagepackages/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. @PersistentModel records facts. Spec 0008 (SqlProjection) is proposed, not frozen, and STATE.md parks it. examples/users keeps users in an in-memory Ref<HashMap> (examples/users/src/services.ts). Use effect/sql directly.
  • 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:*. The effx CLI 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.Problems calls a registry function that your application exports. Domain-error to problem conversion and ProblemBoundaryLive are 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.md are 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:

  1. The body is { "email": "..." } with a format rule and a length limit.
  2. 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.
  3. 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)

routes/api.php
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)

app/Http/Requests/ChangeEmailRequest.php
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)

app/Policies/UserPolicy.php
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)

app/Http/Controllers/UserEmailController.php
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']),
        );
    }
}
app/Services/UserDirectory.php
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.)

app/Http/Resources/UserSelfResource.php
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)

app/Exceptions/EmailAlreadyTaken.php
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.");
    }
}
bootstrap/app.php (the withExceptions part)
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

LayerLaraveleffx declarationEffect underneath
RouteRoute::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
ValidationChangeEmailRequest::rules()ChangeEmailBody and ChangeEmailParams schemas in @Http.Contractparams and payload decoded by the endpoint before the handler
PolicyChangeEmailRequest::authorize() calling UserPolicy::updateEmail@Http.Access({ ... }), the authorize thunk and the handler's canChangeEmail checkguards["changeEmail"](request) and a plain function
ServiceUserDirectory injected by the container@Requirements(Users)Context.Service and Layer (examples/users/src/services.ts)
Not found, conflictModelNotFoundException as 404; EmailAlreadyTaken rendered as 409@Errors(...) and @Http.Problems({ registry, codes, map })typed E; error: with httpApiStatus 404, 409, 403
ResponseUserSelfResourcesuccess: User.Selfthe 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 HttpApiBuilder before the handler is called (packages/effect/src/http-api/HttpApiBuilder.ts).
  • Three outcomes with fixed statuses: 404, 409 and 403. Unlike Laravel's render closures, each is a typed member of the operation's error channel, so the generated client (DeclaredFailures in client.ts) and the OpenAPI document see them.
  • The service's typed failures. examples/users/test/e2e.test.ts asserts EmailTaken through the generated RPC client.

What the port does not preserve

  • Access evaluation stays application code. effx records the Http.Access declaration and requires a typed authorize binding. 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 calls FormRequest::authorize() for you before the controller. Here the handler must call authorize() itself.
  • Problem responses follow spec 0005, not RFC 9457. Http.Problems imports your registry and emits error: Registry(...). The registry above only attaches statuses to the existing error classes, so the 409 body is the encoded form of the EmailTaken schema, not the type, title, status, detail body 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, and ProblemBoundaryLive is 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 UserNotFound inside 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.modify on 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 data by default. The effx response is the schema-encoded User.Self.
  • Dropped exposures. The protected variant is HTTP-only. @Rpc and @Cli would need the guard argument too.
  • Wiring changes. AppRoutes becomes AppRoutes(guards). The security marker needs a Layer.
  • Unverified here. The pasted effx snippets were not compiled or built for this page. Run effx build and the TypeScript check on your copy to confirm. If the declaration and the handler disagree, the compiler reports EFFX2201 or EFFX2202 (errors), EFFX2205 or EFFX2206 (problem mapping), or EFFX2503 (a protected operation without a security marker).

Where to go next

On this page