agentic-actions / laravel
One Action class for every surface of a Laravel app: web and JSON routes, the CLI, queued jobs, laravel/ai agents, MCP clients and typed TypeScript.
Requires
- php: ^8.3
- composer-runtime-api: ^2.2
- laravel/framework: ^12.62|^13.15
- laravel/mcp: ^1.0
Requires (Dev)
- inertiajs/inertia-laravel: ^3.3
- larastan/larastan: ^3.3.1
- laravel/ai: ^1.0
- laravel/passport: ^13.8
- laravel/pint: ^1.27
- laravel/sanctum: ^4.0
- orchestra/testbench: ^10.6|^11.0
- pestphp/pest: ^4.0|^5.0
- pestphp/pest-plugin-laravel: ^4.0|^5.0
- spatie/laravel-permission: ^6.0|^7.0|^8.0
Suggests
- inertiajs/inertia-laravel: ^3.3 — flash results on Inertia visits, and #[WithPageContext].
- laravel/ai: ^1.0 — expose actions as tools in your agents, with live copilot rows.
- laravel/passport: ^13.8 — OAuth sign-in for remote MCP clients such as Claude and ChatGPT connectors.
- laravel/sanctum: ^4.0 — per-effect token abilities on an api mount, and the default MCP guard.
- spatie/laravel-permission: ^6.0|^7.0|^8.0 — per-tenant permission scoping through AgenticActions\Tenancy\SpatieTeams.
Provides
None
Conflicts
- laravel/ai: <1.0 || >=2.0
Replaces
None
This package is auto-updated.
Last update: 2026-10-03 13:15:24 UTC
README
Beta. 0.9.0-beta.2 is the current release. The API can still change before 1.0: the changelog lists every change and how to upgrade.
Write an operation once, as an Action class. Mark it #[Expose] and the same class answers a web route (JSON and browser forms, with Precognition), an Artisan command, a tool call from a laravel/ai agent or an MCP client, and a typed TypeScript function. Every caller goes through one pipeline: exposure, token abilities, tenant membership, authorize(), validation, handle(), and an allowlist on the output.
<?php namespace App\Actions; use AgenticActions\Action; use AgenticActions\ActionContext; use AgenticActions\Attributes\Expose; use AgenticActions\Effect; use AgenticActions\Refusal; use App\Models\Post; use App\Models\User; use Illuminate\Contracts\JsonSchema\JsonSchema; use Illuminate\Support\ValidatedInput; #[Expose] final class CreatePost extends Action { protected string $description = 'Create a draft blog post. Publishing is a separate action.'; protected ?Effect $effect = Effect::Write; protected array $touches = ['posts']; protected bool $tenantScoped = false; // Only matters once config('agentic-actions.tenant.model') is set. /** * The post's fields. */ public function schema(JsonSchema $schema): array { return [ 'title' => $schema->string()->max(120)->required(), 'body' => $schema->string()->required(), 'excerpt' => $schema->string()->max(200)->nullable()->description('One line for listings. Left empty when omitted.'), ]; } /** * What the caller gets back. Keys not declared here never leave the server. */ public function outputSchema(JsonSchema $schema): array { return [ 'id' => $schema->integer()->required(), 'title' => $schema->string()->required(), ]; } /** * Any signed-in author may draft a post. */ public function authorize(ActionContext $context): bool { return $context->actor instanceof User; } /** * Save the draft. */ public function handle(ActionContext $context, ValidatedInput $input): Post { $author = $context->actor(User::class); if ($author->posts()->where('title', $input->string('title')->toString())->exists()) { throw Refusal::make(__('You already have a post with that title.'))->on('title'); } return $author->posts()->create([...$input->all(), 'status' => 'draft']); } }
That class is already a JSON endpoint:
curl https://example.com/api/actions/create-post \ -H "Authorization: Bearer $TOKEN" \ -H "Accept: application/json" \ -H "Content-Type: application/json" \ -d '{"title": "Hello", "body": "My first post."}' {"id":1,"title":"Hello"}
And a plain Blade form can post to it:
<form method="POST" action="{{ route('actions.create-post') }}"> @csrf <input name="title" value="{{ old('title') }}"> <textarea name="body">{{ old('body') }}</textarea> <button>Save draft</button> </form>
What that one class gets
| Surface | What you get |
|---|---|
| Web | POST /actions/create-post, named actions.create-post, for JSON callers and browser forms, with Precognition. Mount it in as many route groups as you need (web, api., a tenant group). |
| CLI | php artisan actions:run create-post title=Hi body=… --as=1 |
| TypeScript | createPost() with typed input and output, in the file php artisan actions:typescript writes |
| Agents | a tool in the default toolset, after composer require laravel/ai |
| MCP | a tool on the package's MCP server, for a token with the actions:write ability (MCP) |
| Queue | CreatePost::dispatch($input, $context) runs the same pipeline in a worker, as the caller |
An action that removes something other people rely on (Effect::Destructive) or reaches outside the app (Effect::External) gets the web route and the CLI. MCP clients never reach it. An agent reaches it only when #[Expose(agents: [...])] names a toolset, and each call then waits for the person to confirm it on a card the server builds: see confirmations. A Read or Write action with $askForMissing asks the person, in a form in the chat, for the fields a model's call left out, instead of refusing the call: see asking the person.
Installation
You need PHP 8.3 or later and Laravel 12.62+ or 13.15+. laravel/ai 1.x is optional, for agent tools.
composer require agentic-actions/laravel:^0.9@beta php artisan actions:install
actions:install asks which features your app uses (web routes, agents and a copilot, confirmations, MCP, tenants), publishes the config and the migrations those features need, prints the route lines, and asks before it migrates. Run it again after adding a feature. Setup lists what each feature needs and what the package depends on.
Mount the generated routes inside your own middleware, in routes/web.php:
use AgenticActions\Facades\Actions; Route::middleware('auth')->group(fn () => Actions::routes());
For token clients, run php artisan install:api, add the HasApiTokens trait to your User model as it asks, then in routes/api.php:
use AgenticActions\Facades\Actions; Route::middleware('auth:sanctum')->name('api.')->group(fn () => Actions::routes());
Until one of these lines exists, php artisan actions:list prints both whenever an action is open on the web.
Then record what your actions expose:
php artisan actions:check --update
That writes actions.exposure.json, which you commit. From then on actions:check fails whenever an action's exposure changes (a new route, a new toolset, another effect) until someone reviews the diff and runs --update again. Run php artisan actions:check in CI, or inside your test suite.
For the TypeScript client, install the npm package from the Composer package, so the two versions always match. In package.json:
"dependencies": { "@agentic-actions/client": "file:vendor/agentic-actions/laravel/js" }
The client is also on npm (npm install @agentic-actions/client); install the same version as the Composer package. Run npm install, then php artisan actions:typescript, which writes resources/js/agentic/actions.ts.
Installed that way, the client's imports of React and Inertia resolve to your app's own copies. When Composer installs the package from a local path instead (a path repository, which symlinks a checkout that has its own js/node_modules), Vite follows the symlink and can load a second React or Inertia: React reports an invalid hook call, or the client reloads through a router that is not your app's. Dedupe them in vite.config.ts:
export default defineConfig({ resolve: { dedupe: ['react', 'react-dom', '@inertiajs/core', '@inertiajs/react'], }, // ... });
php artisan make:agentic-action CreatePost writes a new action that is discovered but exposed nowhere, whose authorize() returns false until you decide who may run it. Actions are discovered under app/; php artisan vendor:publish --tag=agentic-actions-config publishes the config if yours live elsewhere. The tags agentic-actions-lang and agentic-actions-stubs publish the sentences callers and agents read, and the stub make:agentic-action writes from.
Calling an action
Blade
<form method="POST" action="{{ route('actions.create-post') }}"> @csrf <input name="title" value="{{ old('title') }}"> @error('title') <p>{{ $message }}</p> @enderror <textarea name="body">{{ old('body') }}</textarea> @error('body') <p>{{ $message }}</p> @enderror @error('action') <p>{{ $message }}</p> @enderror <button>Save draft</button> </form> @if (session('action')) <p>Saved {{ session('action')['output']['title'] }}.</p> @endif
On success the visitor is redirected back with a 303, and session('action') holds ['name' => 'create-post', 'output' => ['id' => 1, 'title' => 'Hello']]. Override redirectTo() on the action to send them somewhere else. Invalid input comes back the way it does from a form request: errors in the default bag (or the action's $errorBag), and the old input without your exception handler's dontFlash keys. The duplicate-title refusal lands on title because of ->on('title'); a refusal without a field lands on action.
Token client
A Sanctum token calls the api. mount. Sanctum's default token (['*']) passes the ability check for every effect. To narrow one, grant an ability per effect:
$token = $user->createToken('importer', ['actions:read', 'actions:write'])->plainTextToken;
| Ability | Reaches |
|---|---|
actions:read |
Read actions |
actions:write |
Write actions |
actions:destructive |
Destructive actions |
actions:external |
External actions |
tenant:{id} |
only that tenant, by its primary key; the token then reaches no action outside a tenant |
From another application:
$post = Http::withToken($token) ->acceptJson() ->post('https://example.com/api/actions/create-post', [ 'title' => 'Hello', 'body' => 'My first post.', ]) ->throw() ->json();
A token without the ability gets a 404, the status an action that does not exist gets. The web mount never reads a bearer token. A guard that is neither the session nor Sanctum reads as having no abilities until you bind your own reader (recipe), and php artisan actions:list shows how each configured guard is read.
curl
curl https://example.com/api/actions/create-post \ -H "Authorization: Bearer $TOKEN" \ -H "Accept: application/json" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: $(uuidgen)" \ -d '{"title": "Hello", "body": "My first post."}'
| Case | Status | Body |
|---|---|---|
| Success | 200 | {"id": 1, "title": "Hello"}: only the keys outputSchema() declares, or {} |
| Invalid input | 422 | {"message": "…", "errors": {"title": ["…"]}} |
| A refusal on a field (the same title twice) | 422 | {"message": "…", "errors": {"title": ["You already have a post with that title."]}} |
| A refusal without a field | 409, or the status it sets | {"message": "…", "code": "…", "details": {}} |
Token without the ability, not a member of the tenant, shouldRegister() said no |
404 | {"message": "Not found."} |
| No such action on the web (unknown, or not exposed there) | 404 | your app's usual 404: no route matches |
authorize() said no |
403 | {"message": "You are not allowed to do this."} |
| No user | 401 | {"message": "Unauthenticated."} |
Idempotency-Key is optional. An action that needs one calls $context->requireIdempotencyKey(), which answers 428 when the header is missing. Send Precognition: true to validate without running the action.
Livewire
<?php namespace App\Livewire; use AgenticActions\ActionContext; use AgenticActions\Refusal; use App\Actions\CreatePost; use Illuminate\Support\Facades\Auth; use Livewire\Component; final class CreatePostForm extends Component { public string $title = ''; public string $body = ''; public function save(): void { try { CreatePost::run(['title' => $this->title, 'body' => $this->body], ActionContext::http(Auth::user())); } catch (Refusal $r) { throw $r->toValidationException(); } $this->redirectRoute('posts.index'); } }
run() goes through the same pipeline in-process. It returns what handle() returned, throws a ValidationException for invalid input (Livewire shows it as field errors), and throws a Refusal for anything else that stopped the call. toValidationException() puts the refusal's message on its field, here title, or on action. The same three lines work in a Blade controller.
Other front ends
@agentic-actions/client has no dependencies. callAction(createPost(), { title, body }) posts JSON with an Idempotency-Key and, on the same origin, the XSRF header; it resolves the typed output or throws ActionValidationError, ActionRefusedError or ActionFailedError. After each success it hands the action's $touches (here ['posts']) to every handler registered with onTouched(), so your own store or cache can refetch what went stale. On Inertia, @agentic-actions/client/inertia reloads the props those keys name, and @agentic-actions/client/react wraps Inertia's useHttp in useAction() (recipe).
Agents
After composer require laravel/ai, an agent receives the actions of its toolsets as tools:
<?php namespace App\Ai; use AgenticActions\ActionContext; use AgenticActions\Ai\Concerns\InteractsWithActions; use AgenticActions\Attributes\UseToolset; use App\Models\User; use Laravel\Ai\Contracts\Agent; use Laravel\Ai\Contracts\HasTools; use Laravel\Ai\Promptable; #[UseToolset] final class BlogAssistant implements Agent, HasTools { use Promptable; use InteractsWithActions; public function __construct(public User $user) {} public function instructions(): string { return 'You help the signed-in author manage their blog posts.'; } protected function actionContext(): ActionContext { return ActionContext::agent($this->user); } }
The tools are built for that user on every turn, from the agent's own state. The agent sees only the actions of its toolsets that pass for this user before any arguments exist: shouldRegister(), membership and an authorize() that takes no input. An authorize() that takes ValidatedInput runs when the model calls the tool, so put role and permission checks in an authorize() without input, and row checks in handle() or an input-aware authorize() (what the tool list shows). Each tool answers the model with a short sentence, such as "Done." or "Not done. Rejected: title (max).", and exception messages and submitted values never reach it. Run php artisan actions:check --update after installing laravel/ai: create-post joining the default toolset shows up in actions.exposure.json as a change to review.
MCP
MCP clients such as Claude Code, Cursor and Claude Desktop reach the same actions through one server the package mounts at mcp/actions, once the app has a token guard (php artisan install:api). Each client connects with a person's token, and a token lists only the abilities it names: actions:read, actions:write, and tenant:{id} to bind it to one tenant.
$token = $user->createToken('Claude Code', ['actions:read', 'actions:write'], now()->addDays(90))->plainTextToken;
claude mcp add --transport http my-app https://example.com/mcp/actions --header "Authorization: Bearer $TOKEN"
The client sees the Read and Write actions whose #[Expose] allows MCP, runs them as that person through the whole pipeline, and reads the same sentences an agent reads. Sanctum's default ['*'] token lists nothing over MCP. See docs/mcp.md for the tenant path, the throttle, Cursor and Claude Desktop.
Remote clients that sign in with OAuth, such as a Claude custom connector or ChatGPT, connect once the app adds Laravel Passport and names its guard in mcp.middleware: the person approves one URL on a consent screen, and the client's token reaches only that URL (OAuth).
Copilot
Stream the agent into your page and each tool call shows up as a live row, "Saving…" and then "Draft saved", while the page reloads the props the write touched. The package ships the pieces. Your app gives the agent above laravel/ai's Conversational contract and RemembersConversations trait, so each turn is stored, and writes one route:
use AgenticActions\Streaming\ActionsProtocol; use AgenticActions\Streaming\ChatRequest; Route::middleware('auth')->post('/assistant', function (Request $request) { $chat = ChatRequest::from($request); if ($chat->isEmpty()) { return response()->json(['message' => __('agentic-actions::stream.empty', ['max' => config('agentic-actions.agents.max_message_length')])], 422); } return (new BlogAssistant($request->user())) ->continueLastConversation($request->user()) ->stream($chat) ->usingProtocol(new ActionsProtocol); });
ChatRequest reads only the newest message's words, since the history comes from laravel/ai's conversation store. With tenants, Actions::conversation(BlogAssistant::class, $user, $team) keeps one conversation per person, agent and tenant (one conversation per tenant). ActionsProtocol sends the stream useChat reads, built from an allowlist: the reply's words, one data-action row per tool call, and nothing of the tools' arguments or results. On the page, actionsChat() from @agentic-actions/client/ai-sdk wires a useChat panel, <ActionActivity> renders the rows, and useActionSync() reloads what went stale, holding back while an editor has unsaved work. #[WithPageContext] tells the agent which Inertia page is open. See docs/copilot.md for the whole recipe.
Confirmations. A Destructive or External action that names the agent's toolset runs only after the person confirms that call on a card the server builds, from their own session, once. See confirmations.
Asking the person. When the model calls a Read or Write action with $askForMissing and leaves fields out, the chat shows a form in the shape of MCP's form elicitation, built on the server from the action's schema and ask(). The action runs once the person submits it, with their values, which the model never reads. See asking the person.
Tables. A Read action that implements ShowsTable declares its columns and returns rows. The person sees them as a table in the chat, drawn by your page with <ActionTable> from @agentic-actions/client/views, and a chart your own components draw. The model reads a short copy and says what stands out, so the numbers the person reads are the query's, never retyped. A table is kept with its conversation for reloads, and can be refreshed. See tables.
Testing
$fake = Actions::fake([CreatePost::class => ['id' => 1, 'title' => 'Hello']]); // ... the code under test calls CreatePost::run(), a route or an agent tool ... $fake->assertRan(CreatePost::class, fn (array $input) => $input['title'] === 'Hello');
The ActionAssertions trait adds assertToolset() and assertAgentTools() to a PHPUnit or Pest test case. See docs/testing.md.
Documentation
- Setup: what the package depends on, what each feature needs (packages, tables, routes, config), and
actions:install - Concepts: context, surfaces, effects, doors, exposure, the manifest and the snapshot, tenants, queued runs and the change feed
- Copilot: live rows while an agent works, confirmations before Destructive and External calls, the page following its writes and writes made elsewhere, and the page context
- Asking the person: a form in the chat for the fields a model's call left out, what it can hold, and why it never asks for secrets
- Tables: a Read action's rows as a table the person sees, the model's short copy, the chart, and tables kept with the conversation
- MCP: the MCP server, its tokens and tenant paths, connecting Claude Code, Cursor and Claude Desktop, and OAuth for Claude and ChatGPT connectors
- Security: what the package guarantees, and where each guarantee stops
- Testing: fakes, toolset assertions, confirmations and forms, queued runs and tokens in tests
- Recipes: strict agent schemas, controllers and Livewire, discovery layouts, custom token guards
- Migrating from laravel-actions
Security
Please report vulnerabilities privately, as SECURITY.md describes.
License
Agentic Actions is open-source software released under the MIT license.