Laravel
e ships with PHP/Laravel-aware features on top of the
Intelephense language server.
Inspired by the official Laravel VS Code extension, e introspects your project
(via php artisan and the filesystem) to provide completion, hover and
navigation for Laravel's helpers. It is enabled automatically in any project
with an artisan file; toggle it under Settings → Laravel features (or the
laravel config key). The data refreshes itself: saving a route, config, lang
or .env file in e reloads it at once, and a change on disk from outside —
php artisan make:controller, a git checkout — is noticed within a couple of
seconds. Laravel: Refresh Project Data (⌘⇧P) is still there for a forced
reload.
Helper completion
When the caret is inside one of Laravel's helpers, e offers completions sourced
from your project:
| Helper | Completes |
|---|---|
route('…') |
named routes (with method + URI) |
view('…') |
Blade view names |
config('…') |
config keys (with resolved value) |
env('…') |
environment variables (with value) |
__('…'), trans('…'), @lang |
translation keys (with text) |
<x-…> |
Blade components |
<x-alert … |
the component's props as attributes (from @props([…]) or the class's constructor) |
@include, @extends, @each, @component |
view names (with hover and go to definition) |
These are read from your project, so they reflect your actual routes, views,
config, .env, language files and components.
Hover & go to definition
- Hover (
F1) over a helper string shows the resolved value — a config value, a route's method/URI/action, an env value, or a translation's text. - Go to definition (
F12) jumps to the target:route('…')→ the controller methodview('…')→ the Blade fileconfig('…')→ the config file (and the key's line)env('…')→ the.envline__('…')→ the key's line in the language file, in the app's locale (nested keys and JSON files included)<x-…>→ the component's Blade file, or its class inapp/View/Componentsfor a class-based componentcan('update', $post),@can,Gate::allows→ the method on the policy for the model in the second argument (PostPolicy::update)- an event class → the class itself; from where it is dispatched
(
event(new …),…::dispatch(), its listener
Find Usages and Rename for keys
The keys Laravel resolves at runtime — route names, view names, translation
keys — are strings no PHP language server follows. On one, Find Usages
(⇧F12) lists every place it appears across PHP, Blade and, for routes,
Ziggy's route() in your JS, declarations first: the ->name('…'), the view
file, the key's line in each language. In a Blade view with no key under the
caret, it answers who renders this view?
Rename (F2) on such a key rewrites all of them through the usual preview,
and for a view moves its file to match. It refuses what it couldn't finish: a
route name built from a group's Route::name('admin.') prefix (rename it
where it is declared), or a translation key whose group would change (that
would move it to another file).
Blade templates
*.blade.php files are detected as Blade with full syntax highlighting:
HTML tags, attributes and Tailwind utility classes, Blade directives
(@php, @if, @foreach, @push, …), {{-- comments --}}, and the embedded
PHP inside @php … @endphp blocks, {{ … }} and {!! … !!} expressions.
<script> blocks are JavaScript and <style> blocks CSS.
Query-builder completion
Inside a query builder, column names complete from the model's table and the
live schema — where('…'), orderBy(), select(), pluck(), value(),
groupBy(), having() — and relationship names complete inside with('…'),
load(), whereHas(). The table is resolved from Model::, $model, or
DB::table('…') — and inside a closure from what the closure queries:
whereHas('posts', fn ($q) => $q->where('…')) completes posts columns
(dotted relations followed), a grouped where(fn ($q) => …) stays on the outer
table, and a closure that can't be told is left alone. Columns that don't exist
in the schema are underlined with a warning — a check PhpStorm can't do without
the database.
Laravel lints
From the same project data, e underlines what would fail at runtime, as you
type (300 ms after the last keystroke, off the UI thread):
| In | Check |
|---|---|
view('…'), @include, @extends, @each, @component |
the view file exists (error) |
route('…'), to_route('…') |
the route is defined (error); its required {parameters} are passed (warning) |
__('file.key'), trans(), trans_choice(), @lang |
the key exists in lang/ (warning; sentence keys are JSON translations that fall back to themselves) |
.env |
every key .env.example declares is set (warning at the top of the file) |
env('…') outside config/ |
warning: it returns null once the config is cached |
Arguments that are expressions ('a.'.$b, $name) and namespaced names
(pkg::view) are left alone. When the official laravel/lsp is running it
reports missing views and routes itself, so these lints stay off then; the
env() check stays on, as it doesn't make that one.
Two of them come with a fix in the code-action picker: Create
resources/views/…blade.php for a missing view (the file opens once created),
and Add key to lang/APP_LOCALE from .env (else app.locale, else en) and the
file is created around the key when it doesn't exist yet.
env() outside config/
php artisan config:cache — which every deploy should run — stops Laravel
from loading .env, and from then on every env('…') outside the config
files returns null. A controller that reads env('STRIPE_WEBHOOK_SECRET')
works on every laptop and fails in production. e underlines such a call in
PHP and in a Blade template's PHP ({{ env('APP_NAME') }}, a directive's
arguments) — not in config/, tests/, a package's own config/, or a
template's markup, where it is text.
⌘. on the call reads the value through config() instead:
- Use config('app.url') instead of env('APP_URL') — when a config file already reads the variable, the call becomes that key.
- Move env('STRIPE_WEBHOOK_SECRET') to config/services.php and read
config('services.stripe.webhook_secret') — otherwise the variable gets an
entry in
config/services.php, named for its prefix: in the service's array when there is one, a new array when not, and the file when there isn't one. A default (env('X', 'fallback')) moves along with it.
Either way the variable is added to .env.example when it's missing there.
The agent review flags the same call in a diff, and a config file that starts
reading a variable .env.example doesn't have
(Automated flags).
Package completions (ide.json)
Packages that ship a Laravel Idea ide.json
— and your own project's ide.json — teach e their string arguments: new Axis('…') completes the strings the package lists, ->rule('…') on its
validation class completes rule names, a function it declares as taking a route
name completes your routes. e reads ide.json from the project root and from
every vendor/*/*/ on load, and honours the completions section: kinds
routeName, viewName, configKey, translationKey, environmentVariable,
bladeComponent, validationRule, staticStrings, gate/policy,
inertiaPage, bound by function, method, constructor, parameter position and
place (parameter or array value). Receiver types aren't inferred, so a
->method('…') rule matches by method name.
Refactorings (code actions)
Alongside the language servers' quick fixes, the code-action picker offers
e's own Laravel refactorings at the caret:
- Convert validation string to array —
'required|max:255'→['required', 'max:255']. - Convert to
[Controller::class, 'method']— from the string'UserController@index'form. - Convert
{{ }}↔{!! !!}in Blade. - Convert
scopeActive()to#[Scope] active()(Laravel 12.6+), adding theuse Illuminate\Database\Eloquent\Attributes\Scope;import when missing. - Promote to FormRequest — with the caret in
$request->validate([…])(or$this->validate($request, […])): createsapp/Http/Requests/StoreUserRequest.php(named from the method and controller) holding the rules, replaces the call with$request->validated(), retypes the method'sRequestparameter and adds the import. The new class opens.
Carry a field through the stack
Add a field to a form, and ⌘. on it offers Carry “phone” through the stack.
e follows the form to where it is handled and shows every edit the field
needs, in one preview, before writing any of them:
Carry “phone” through the stack?
3 sites in 3 files, and a new file
resources/js/Pages/Customers/Create.vue
4: + phone: '',
app/Http/Requests/StoreCustomerRequest.php
12: + 'phone' => ['nullable', 'string', 'max:255'],
app/Models/Customer.php
15: + 'phone',
database/migrations/2026_10_26_120000_add_phone_to_customers_table.php (new)
+ $table->string('phone')->nullable();
- Where it starts:
form.phone(orform.errors.phone, or a key of theuseForm({…})itself) on an Inertia page;name="phone"in a Blade<form>;wire:model="phone"orwire:model="form.phone"in a Livewire view. - The form itself: an Inertia form gets the field in its
useForm({…}), and in theuseForm<CustomerForm>interface when the page declares one. A Livewire component, or its form object, gets the public property. - Validation: wherever the handler validates — the action's FormRequest
rules()(also when it builds$rulesand returns it), avalidate([…])orValidator::make()in the method, a Livewirerules(), or a#[Validate]on the new property when its neighbours have one. The rule follows the style beside it, array or'a|b'. Fortify's profile and registration forms go toapp/Actions/Fortify, where the field is validated and filled too. - The model the handler writes (
Customer::create(),$customer->update()on a bound model,$request->user()->update(),->notes()->create()): the field joins$fillable, and gets a cast when it needs one (is_vip→boolean,shipped_at→datetime). - The column, from the live schema (or, without a database, the
migrations): when the table lacks it, a migration adds it, with
down()dropping it again.
What the field is comes from its name and the value the form starts it with:
customer_id is a foreign key (exists:customers,id, foreignId()->constrained()),
is_…/has_… or false a boolean, …_at a timestamp, notes text,
unit_price a decimal, 0 an integer, anything else a string. Steps that are
done already are left alone, so running it again changes nothing; what it
couldn't do — a route handled by a package, a model it couldn't name — is
said under the list.
Validation rules
Rule names complete where rules are written — FormRequest rules(), a
$rules property, validate([…]), Validator::make() and Livewire's
#[Validate(…)] — and not after any => ' elsewhere. So do
the tables and columns exists: and unique: name — exists:us offers your
tables, exists:users, their columns, from the live schema; Rule::exists('…')
and Rule::unique('users', '…') likewise. The command Laravel: Generate
Validation Rules from Table writes 'field' => 'rules' lines from the live
schema (nullable → nullable, varchar(255) → max:255, and so on) at the
cursor.
Tests: run the one at the caret
⌘⌥⇧T runs only the test the caret is in — a Pest it('…') / test('…')
description or a PHPUnit test_… / #[Test] method — as php artisan test <file> --filter="…", in the TDD panel, so a failure still has a line to jump to.
The next ⌘⇧T runs the whole suite again.
Each test's latest result is marked in the gutter beside its declaration: a
tick when it passed, a red cross when it failed, a dash when it was skipped. A
test run once per data set shows its worst result. Running one test updates its
mark and leaves the others' as they were. Click a mark to run that test again.
The marks come from the runner's JUnit report, so they appear for PHPUnit and
Pest. They are kept per project (~/.config/e/tests/) and come back the next
time you open it.
Tests on save
With Settings → Laravel → Run affected tests on save on, saving a PHP file or a view runs the tests that exercise it, in the background, without the Tests panel:
- a test file runs itself;
- a model, service or any other class runs the tests that
useit or name it in full, and the one named after it (OrderTest.phpforOrder); - a controller also runs the tests that call one of its routes, by name
(
route('orders.store')) or by path ($this->post('/orders')); - a Blade template, a Livewire or Volt component's view, or an Inertia page
runs the tests of the classes that render it (
view('orders.index'),Inertia::render('Orders/Index')) — so a controller's routes and a Livewire component'sLivewire::test(…)— and the tests that name it:assertViewIs('orders.index'),->component('Orders/Index'),Volt::test('orders.table').
The gutter's marks update when the run is done, and the status bar says how it
went — ✓ 12 passed or ✗ 1 failed, 11 passed. Click it for the output in the
Tests panel. Saves close together run once; a save during a run waits for it.
A save that touches more than twelve test files is left to a full run, and the
status bar says so. Tests reached only through other classes aren't found —
run the suite (⌘⇧T) for those.
The Laravel menu
⌘⇧, opens the command palette filtered to Laravel: — every Laravel command
in one list, the way Laravel Idea's menu gathers them: code generation, the
Artisan palette, route search, the Eloquent helper, migrations, the maps and
panels. Type to narrow it further.
Code generation
Laravel: New Eloquent Model… (⌘⇧N) is a spec in an editor panel with a
live preview of the files it will create. Fields have a type, ? for nullable
and an optional default; foreignId Customer is a foreign key and a
belongsTo. Relations, options (id timestamps softDeletes fillable, prefix
- to turn one off) and what to generate are each one line:
model: Order
fields:
customer_id foreignId Customer
status string = 'pending'
total decimal(10,2)
notes text?
relations:
hasMany OrderLine
options: id timestamps fillable
generate: migration factory seeder request controller api-controller resource policy
⌘↵ (or Create) writes the model with $fillable, casts() and the
relation methods, plus whatever generate: lists: the create_orders_table
migration; a factory with fakes chosen from the column types and names; a
seeder; StoreOrderRequest/UpdateOrderRequest with rules derived from the
fields (required|string|max:255, nullable|string, exists:customers,id,
sometimes on update); a resource controller that uses those requests (or an
API one under App\Http\Controllers\Api returning the JSON resource);
OrderResource; OrderPolicy. Files that already exist are left alone and
listed. The language servers are told about the new files at once, so they
complete immediately.
Laravel: New Pivot Table… is the same panel with a pivot: Post Tag line:
one migration for post_tag with both foreign keys, a composite primary key
and (unless options: -timestamps) timestamps.
Laravel: New Eloquent Model from Database Table builds the model from the
live schema instead (see below). Laravel: New Class… is the Artisan palette
opened on make: — every generator the app and its packages declare
(controller, job, event, listener, mail, notification, command, Livewire
component, …), with Artisan's own usage line as the hint for the arguments.
Artisan
⌘⇧A opens the Artisan palette with every command the app declares (its own
and its packages'), make: first. Pick one, and the usage line becomes the hint
for an argument line: Order -mf for make:model, --seed for migrate.
A make: command runs in the background and opens what it created — the model
and, with -mf, its migration and factory. Anything else runs in a terminal
tab, so prompts and output work as in a shell.
Eloquent helper
Laravel: Generate Eloquent Helper writes _ide_helper_models.php in the
project root: for every model, @property per column with its PHP type from the
live schema, @property-read for each relationship (a Collection<int, Post>
plus posts_count on the many side), and @method static Builder for each
scope (scopeActive() or #[Scope]). Intelephense reads it like any file, so
$user->posts, $order->total and User::active() complete and type-check
everywhere — in closures, on collections, in Blade. Once the file exists it is
regenerated whenever the schema is reloaded, and the language servers are told
about it at once. Add it to .gitignore.
Routes: controller actions
In a route file, [UserController::class, ' completes the controller's public
methods, resolving the class through the file's use imports and Composer's
PSR-4 map.
Route search
⌘⇧R lists every route, named or not — GET /users/{user} with its name and
action — and filters as you type on method, URI, name or controller. Enter
opens the controller action, or the routes-file line for a closure route (also
inside a Route::prefix() group).
Find unused views
Laravel: Find Unused Views scans app/, routes/, config/ and the views
themselves for view(), @include/@extends/@each/@component,
Route::view(), View::make(), mailables' ->view()/->markdown(),
->layout()/#[Layout], 'view' =>/'layout' => entries and <x-…> tags,
and lists the Blade views nothing refers to. Livewire views and single-file
components, errors.* and vendor.* are resolved by convention and left out.
A view named from a variable can't be seen, so read the list as "worth a look";
Enter opens one.
Gates & policies
can(), authorize(), @can, and Gate::allows() complete ability names and
jump (F12) to the policy method or Gate::define() that declares them.
Generate model from table
With a table open in the database panel, Laravel: Generate Model from Table
creates an Eloquent model from the live schema — $fillable, $casts, and
belongsTo/hasMany relationships inferred from the real foreign keys.
Event dispatch graph
⌘⌥G opens the event → listener graph, built from $listen, Event::listen(),
and auto-discovered handle(EventType $event) listeners. F12 on a dispatched
event class jumps to a listener.
Related files
⌘⌥E shows every file for the current resource — model, migration(s), factory,
seeder, controller, policy, request, resource, and test — in a quick picker.
Livewire
e treats a Livewire component's class and Blade view as one unit — in all
three shapes a component can take:
- class-based:
app/Livewire/Admin/UserTable.phpwithresources/views/livewire/admin/user-table.blade.php; - single-file (Livewire 4, and Volt on Livewire 3): one Blade file whose
<?php … ?>block holdsnew class extends Component { … }, such asresources/views/components/⚡counter.blade.php; - multi-file (Livewire 4):
⚡counter/counter.phpbeside⚡counter/counter.blade.php.
Then:
wire:model="…"completes from the component's public properties — in a single-file component from the buffer you're editing, unsaved changes and all.F12on a property in the view jumps to its declaration;⌘⌥Jswitches between a view and its class.- Renaming a property with
F2updates both the class ($prop,$this->prop) and everywire:reference and$propin the view — in one edit when they share a file. wire:click="…",wire:submit,wire:changeand the other event attributes complete from the component's action methods (public, minus lifecycle hooks).<livewire:…>and@livewire('…')complete every component by the name Livewire knows it by —admin.user-table,counter,pages::dashboard(Livewire 4'spages::andlayouts::namespaces; Volt pages unprefixed on Livewire 3, read fromcomposer.lock) — andF12on one, or onRoute::livewire('/', 'pages::dashboard'), opens it.
Runtime insight
⌘⌥I opens a continuous, Telescope-style panel that captures every request
against your dev app. No Telescope or Debugbar install required.
With Grove serving the project, the panel
reads Grove's own request timeline — Grove is the proxy, so every request is
there with nothing installed in the app: method, path, status and duration.
Expanding a request fetches its causal chain from Grove: the SQL it issued
(turn on SQL capture in the panel header, which runs grove sql-capture on;
MySQL), the mail it sent, and the matching error-log entries. ✨ hands the
agent Grove's whole explain bundle — the request with credentials redacted, its
queries and mail, and the stacktrace from laravel.log — so it can go straight
to the cause. 🧪 writes a feature test from the request
(below). The replay base URL also comes from Grove
(the real host, and http:// for a site without HTTPS).
Without Grove, the panel polls Clockwork
(/__clockwork/latest) as before: queries with N+1 warnings, cache hits/misses,
sent mails, and events.
Verify the fix (below) takes its query counts from Grove's chain too when the app has no Clockwork — turn on SQL capture first. Grove 1.12.4 or later is needed for it to see a Laravel app's queries; older versions missed PDO's prepared statements.
Exceptions on their line
When the running app logs an exception, it shows on the line that caused it: in red at the end of the line — ✖ ErrorException: Undefined variable $orders · 20:15:03 — with a squiggle and an entry in Problems, and in the status bar (✖ OrderController.php:48 ErrorException), which takes you there with a click, also when the file isn't open.
The line is the first one in your own code the exception passed through: one
thrown inside the framework — a model not found, a failed query — lands on the
call in your controller or service that led to it, not in vendor/. A Blade
error lands in the template, on the undefined variable when that's what it
was. Paths from a container (Sail, Docker) are mapped onto the project, and a
message over several lines — a Postgres error with its LINE 1: and caret —
is read whole.
⌘. on the line offers Exception: ask the agent to fix …, which hands the
agent the log entry with its stacktrace, and Exception: dismiss. An
exception stays with its line as you edit — lines added above push it down,
and deleting its line removes it. Saving the file clears its exceptions — the
fix is usually why you saved — and the next failing request brings them back.
e reads storage/logs/laravel.log (or the newest daily
laravel-YYYY-MM-DD.log) every two seconds, from where it was when the
project opened, so it works with whatever serves the app: Grove, Herd,
artisan serve or Sail. Only exceptions logged at error level or worse with
a location count.
Failed jobs on their handle()
A queued job, listener, notification or mail that fails shows on the class
that runs it — in red at the end of its handle() line (or __invoke(), or
the class line): ✖ Failed job SyncOrders: RuntimeException: The warehouse
said no · 14:02:11, with a squiggle, an entry in Problems and the status bar,
like an exception from the log. Several failures of one job share the line
(SyncOrders ×3). A class the project doesn't have lands where it threw in
your code instead.
⌘. on the line offers:
- Failed job: retry SyncOrders (queue:retry) — runs
php artisan queue:retryfor every failure on that line, which pushes them back onto their queue for a worker to pick up. - Failed job: ask the agent to fix … — hands the agent the job's class,
queue, the models it was dispatched with (
App\Models\Order #14) and the stacktrace. - Failed job: dismiss.
The mark goes when the job leaves the table — retried, or forgotten from a terminal — and saving the file leaves it be: the job is still failed until it is retried.
e reads the failed_jobs table every ten seconds — a count and the newest
id, and the rows only when those change — from the database .env names
(SQLite, MySQL/MariaDB or PostgreSQL), never one that looks like production.
One it can't reach is asked again once a minute.
Runtime lenses
What the running app just did shows at the end of the line of the controller method that did it:
public function index(Request $request) ⚡ 42 ms · 14 queries · N+1 orders ×12 · 2m ago
Each captured request is matched to the route the router would pick, and the
route to its action; the newest request per action is the one shown, with the
number of requests when there were several. The action's class is found
through composer.json's PSR-4 map, so a controller under Domain\ or
Modules\ gets its lens as well as one in app/. A Livewire page or an
invokable controller gets its lens on the class line. The lens turns yellow for an N+1
or a request over a second, and red for a server error. Queries are shown only
when they could be seen — Clockwork in the app, or Grove with SQL capture on —
never as a guessed zero.
⌘. on the line offers Runtime: show (the request, expanded, in the Runtime
panel) and Runtime: ask the agent to fix … and measure it: the agent gets
the request with its queries and stacktrace, and is told to measure the route
with verify_route before and after its
change and to report that verdict.
With Grove, the timeline is read every two seconds while one of the project's
classes is open (under app/ or another autoloaded directory), so the lenses follow your browser without the Runtime panel open; with
Clockwork, they follow while the panel is open. Turn them off under
Settings → Laravel → Runtime lenses.
A test from a request
Click through something in the browser, then 🧪 on its row in the Runtime
panel (or Runtime: write a test from … under ⌘. on a lens): e writes
the feature test that replays it, in the project's style, and opens it.
test('PUT /orders/{order}', function () {
$user = User::factory()->create();
$order = Order::factory()->create(['user_id' => $user->id]);
$this->actingAs($user)
->put(route('orders.update', $order), [
'status' => 'paid',
'note' => 'Leave at the door',
])
->assertSessionHasNoErrors()
->assertRedirect();
$this->assertDatabaseHas('orders', ['id' => $order->id, 'status' => 'paid']);
});
- Pest or PHPUnit, whichever the project uses. The test goes in
tests/Feature/under the controller's name (Admin/OrderControllerTest.php), at the end of the file when there is one, with the imports it lacks. A new file getsRefreshDatabaseunless the project's tests already reset the database. - The route's models are made with their factories, owned by the signed-in
user when they belong to one (
user_id). A*_idfield in the body that names a model gets one made too. A route behindauthsigns a user in, on the route's guard (actingAs($user, 'sanctum')). - The status becomes
assertOk(),assertRedirect(),assertNoContent()…; a form that redirected also asserts it came back without validation errors. A request that failed becomes a test that expects it to succeed — red until the bug is fixed. - What it wrote, with Grove's SQL capture on, becomes
assertDatabaseHas,assertSoftDeletedorassertModelMissing— only for values the request sent, never ones the app made up such as ids and timestamps. - A Livewire update becomes
Livewire::test('name', [models])->set(…)->call(…)->assertHasNoErrors(). - A secret Grove redacted is sent as
'password'(the factory's) or'secret'.
What the test could not set up — a model without a factory, one that belongs to
a team — is in the notification. So is a warning when phpunit.xml names no
test database of its own, since RefreshDatabase would then empty the app's.
Mail and webhooks (Grove)
Grove: Mail Catcher (command palette) lists every email the app sent to
Grove's SMTP server — subject, recipients, time — and shows a message's text
when you click it; ✨ hands it to the agent to check content and find where it
is built. Grove: Webhooks lists the deliveries captured at
/__grove/hooks/<bucket>; select one and ↻ Re-deliver sends the exact same
request to your handler at that path on the app's own URL, so you can fix the
handler and replay until it answers 200.
Verify the fix (✓)
Click the ✓ on a captured request to verify a change end to end. e
checkpoints your working tree, replays the request and records a baseline
(time, query count, N+1). Apply your fix — edit the code or ask the agent — then
hit Measure again: e replays the request and shows a before/after
verdict (Improved / No change / Regressed / Broke). Keep the change, or
Discard it to revert to the checkpoint.
For Inertia/VILT projects, see Inertia & the VILT stack.
Evaluate an expression where it is written
Put the caret on a PHP expression in a Laravel project, outside a SQL
string, and press ⌘↵:
$orders = Order::whereBelongsTo($user)->withSum('items', 'price')->first();
The value shows at the end of the line, with the queries it ran and how long it took:
⇒ App\Models\Order #14 {id: 14, total: 990, …} · 2 queries · 3.1 ms
- What runs. The whole statement the caret is in runs, across lines. If
you select something, the selection runs instead.
return,echoand a trailing;are dropped, and an assignment gives what it assigns. - How it runs. The code runs in the booted app as the file would see it:
in the file's namespace, with its
useimports. - What the value looks like:
- a model shows its key and first attributes;
- a collection shows its count and first items;
- a paginator shows how many of how many;
- a query builder that hasn't run shows the SQL it would run, with its bindings in place.
- When it fails. A variable that only exists inside the method
(
$user,$this) comes back as an error. Select the part that stands on its own.
⌘. on the line has Evaluate: show the full result and its queries,
which puts the whole value and every query in the Tinker panel, and
Evaluate: clear the result. A result follows its line as the code above
it changes, and goes when its own line changes.
Nothing you evaluate sticks:
- What it writes is rolled back. It runs in a transaction on the default
connection, so
$order->update(…)changes nothing. - Mail, notifications and queued jobs are faked.
ewon't run it when the app boots as production, or when its default database isn't on this machine or hasprodorlivein its name. The check reads the configuration the app actually loaded, so a cached config counts as well as.env.
Some things can't be rolled back or faked: DDL on MySQL (it commits as it runs), HTTP calls, files written, and other connections. Evaluate those expressions with that in mind.
Explain the query
⌥⌘↵ on an Eloquent chain, outside a SQL string, explains the query
instead of showing its value:
return Order::where('status', 'paid')->where('created_at', '>', $since)->orderBy('total')->get();
EXPLAIN ✖ Full table scan on `orders` (type=ALL, no index) — an index on orders (status, created_at) would cover it
What gets explained: the chain runs as an evaluation does, rolled back.
The database's own plan is then asked for each select it ran, on the
connection the app uses: EXPLAIN on MySQL, MariaDB and PostgreSQL,
EXPLAIN QUERY PLAN on SQLite. A chain that ends before it runs (no
get(), first() and so on) is explained without running at all.
The index it suggests is the one the query's where and order by ask
for:
- the columns compared with
=,inoris null; - then the one compared by range;
- or, if there is no range, the columns it sorts by.
No index is suggested when one already starts with those columns, or when
an or at the top of the where means no single index would serve.
⌘. on the line:
- Explain: add an index on orders (status, created_at) writes the
migration (
$table->index([...])inup(),dropIndexindown()) and opens it, where the migration check shows the SQL it will run. - Evaluate: show the full result and its queries shows every plan in the Tinker panel.
Tinker scratchpad
Press ⌘⌥T for a Tinker scratchpad: write PHP and press ⌘↵ to run it against
your app via php artisan tinker, with the output shown below. Select code in
the editor and run Tinker: Run Selection to evaluate it. The AI agent can
also write and run Tinker snippets over the sync socket.
Architecture map
⌘⌥M opens an interactive map of your routes: each row shows
route → controller → views as clickable cards. Click the controller to jump to
its method, or a view to open the Blade file. Filter by route name, URI or
action.
Eloquent completion (live schema)
When you type $user-> on a model instance, e suggests the model's real
database columns — read from the live schema (via .env). It infers the model
from $var = Model::… or a type hint (not from ->get(), ->paginate() or
->count(), which aren't one model), maps it to its table (protected $table
or the snake_case + plural convention), and merges the columns alongside
Intelephense — something Intelephense can't do on its own.
In Blade the variable comes from where the view is rendered:
view('users.show', compact('user')) with a type-hinted User $user,
['user' => $user], ->with('user', …), auth()->user(). A Livewire view
takes its component's typed properties.
The schema follows your migrations: a local database is checked every ten
seconds and re-read when its migrations table changes — however the
migration ran — and open PHP files are re-linted, so a column you just added
is no longer "unknown".
Relationship graph
⌘⌥R parses the relationships from your models (hasMany, belongsTo,
belongsToMany, morph*) and cross-checks them against the live database's
foreign keys. Each model is a node; click a relationship to jump to the related
model or method. Relations that exist in code but have no backing foreign key are
flagged ⚠ — so alongside the schema diff you see code, migrations, and the actual
database in one place.
Security lens
In the architecture map (⌘⌥M) every route shows its middleware stack and a
badge: 🔒 when it's authenticated, ⚠ when a state-changing route (POST/PUT/PATCH/
DELETE) has no authentication. The header counts unprotected routes, and clicking
a ⚠ asks the agent to suggest the right middleware/policy.
Unchecked lookups (IDOR)
A route behind auth with a {parameter}, and a controller method that turns
it into a model — Order $order, or Order::findOrFail($id) — with nothing that
asks whether this user may have that order: any signed-in user can then read or
change anyone's by changing the id in the URL. e warns on the method's line:
GET orders/{order} hands any signed-in user the Order {order} they ask for, and
it belongs to someone (user_id): nothing here checks it is theirs …
It errs towards silence. Only a model that belongs to someone counts — a
user_id, team_id, owner_id… or a belongsTo(User::class) (not
'created_by', which says who made the row) — and not one a global scope
already confines, such as a tenant or team trait. Anything that looks like a
check silences it: authorize(), Gate, ->can(), a FormRequest whose
authorize() does more than return true, can:/role/admin/signed middleware,
authorizeResource() in the controller, scoping to the user
($request->user()->orders()->findOrFail(…), auth()->id(), whereBelongsTo),
or a helper like $this->ensureOwns($order). Slugs and tokens in the URL are
left alone. Run over 81 real projects, it raised 11 warnings in 3 of them.
The same check flags a method a changeset touches in
session review, and marks the route
"unchecked" in the agent's laravel_map.
Generate a test from a replay
After replaying a route (▶ in the map), the 🧪 Test button writes a Pest
feature test to tests/Feature/ using the request path, the response status, and
assertions inferred from the response (JSON structure or an HTML <title>). It
opens the file, ready for the ⌘⇧T "fix to green" loop.
Migrations
Laravel: Migrations (command palette) lists every migration with its batch
and whether it has run, from php artisan migrate:status. ▶ Migrate runs
what's pending — behind a grove db snapshot when Grove serves the database, so
a bad migration is one grove db restore from undone — and ↶ Rollback last
batch runs migrate:rollback --step=1; both in a terminal tab, with the list
re-read once they have had a moment to finish. Click a migration to open its
file.
After a branch switch
Check out a branch with migrations of its own and the app fails on a missing
column until migrate runs; switch back and the database has a migration the
branch knows nothing about. When the project opens, and whenever a checkout,
pull, merge, rebase or reset moves HEAD — in e, in a terminal or by an
agent — e compares the migration files with the database's migrations
table, and a bar above the editor says where they part:
2 migrations on this branch haven't run, and the database has 2026_09_01_000000_add_discount_to_orders, which this branch doesn't
Run migrate runs what's pending, as ▶ Migrate does; Show
migrations opens the list; × puts the bar away until the drift changes.
A migration from another branch can't be rolled back from here — its file is
on that branch — so the bar only tells you; check that branch out to roll it
back. While the bar is up it is re-checked every ten seconds, so a migrate
run in a terminal takes it down.
A commit of your own doesn't raise it — the migration you just wrote not
having run is the work at hand. Migrations packages load from vendor/ count
as files the branch has. The database is the local one .env names, never one
that looks like production.
Before a migration runs
Open or save a migration that hasn't run, and e asks php artisan migrate --pretend what SQL it will run. Each statement shows dimmed at the end of the
line that produces it:
$table->string('phone'); ⇂ alter table `users` add `phone` varchar(255) not null
Then the statements are checked against the data already in your local database, and what would go wrong is marked on the line:
A NOT NULL column without a default, on a table with rows |
error on Postgres, which refuses it; on SQLite always, even on an empty table; on MySQL for a date or time column (strict mode refuses the zero date). On MySQL otherwise a warning: each row gets '' or 0. |
->change() to NOT NULL while rows hold NULL |
error, with how many rows |
->change() to a shorter length than values already have |
error, with how many rows |
| A unique index over values that occur more than once | error |
| A foreign key that rows have no parent for | error |
dropColumn / drop on data |
warning: down() can bring back the column or table, not what was in it |
| A type change on a table with rows | info: it rewrites the table, which on a large production table holds a lock |
Only counts are run against the database, and nothing when the .env
connection looks like production. A migration that has already run is left
alone.
On Postgres and SQLite, ⌘. in a migration also offers Migration: dry-run
up() and down(), rolled back. e runs up() then down() (for one that has
run, down() then up()) inside a transaction it then rolls back, so the
database is left exactly as it was; a method that throws gets the error on its
line. MySQL commits schema changes as it makes them, so there it isn't offered.
Code in a migration that reaches outside the database — files, queues, HTTP —
does run, and isn't undone.
Schema diff
Laravel: Schema Diff (command palette) compares your migrations against the live database and lists discrepancies — columns that exist in the DB but no migration creates, and columns a migration adds that aren't in the DB yet.
Log tail
⌘⌥L opens a live tail of storage/logs/laravel.log: levels are coloured,
stack-trace frames are clickable (jump to file:line), and Fix with AI hands
the latest error to the agent.
Request replay
In the architecture map (⌘⌥M), click ▶ on a GET route to replay the request
against your running app and see the response — plus the SQL queries it ran
(if the app has laravel/clockwork), with N+1 duplicates flagged. The base URL
is what Grove serves the project as when Grove is running (else
https://<folder>.test); override it under Settings → Laravel → App URL.
"Explain with agent" hands the analysis to the AI panel.
Working on a Laravel project
- Open the project root:
e ~/code/my-laravel-app. - Ensure
intelephenseis installed and on yourPATH. - Use
⌘Pto jump between controllers, models, and views;⌘⇧Oto find classes and methods; and the Source Control panel for commits.
Tips
- The AI agent panel (
⌘L) is handy for Laravel scaffolding and refactors — point it at your project and let it work alongside you. - Use workspace search (
⌘⇧F) to find usages across Blade views and PHP classes at once.
Pint and PHPStan
Both are picked up from vendor/bin — a project that doesn't use them gets
nothing, with no configuration and no behaviour change.
Pint becomes the formatter for PHP files. When the project ships
vendor/bin/pint it takes precedence over the language server, because a
Laravel project's formatting is whatever Pint says it is — that's what CI
enforces, and letting Intelephense format to its own taste would only produce a
diff for Pint to undo. It respects your pint.json: the buffer is formatted
through a temporary file beside the original, so a preset scoped to app/
sees the file as being in app/.
PHPStan runs on save, over the file you just saved rather than the whole
project, and its findings appear as warnings alongside the language server's.
Each carries PHPStan's rule identifier (variable.undefined) as the diagnostic
code, so you can look it up or baseline it. Larastan works too — it ships the
same binary.
It only runs when the project has both vendor/bin/phpstan and a config
(phpstan.neon, phpstan.neon.dist or phpstan.dist.neon), since PHPStan
needs the config to know its level and paths. Findings cover the whole line:
PHPStan reports a line but no column, and guessing a span would put the squiggle
under the wrong token.
If PHPStan itself fails — a broken config, a path that doesn't exist — the error is reported rather than swallowed. A run that never looked at your code must not be mistaken for a clean one.
Test results
Press ⌘⇧T for the test panel, or Run tests from the
ship gate.
Where the runner can write JUnit XML — php artisan test, Pest, PHPUnit,
Vitest — the panel lists each failing test with its first assertion line, and
clicking one opens the file at the failing line. The toolbar shows
12 passed · 2 failed · 1 skipped, because the suite failed and two of ninety
tests failed are different news.
Runners that can't produce a report are run exactly as before, with their plain output. Nothing is passed a flag it doesn't take.
PHPUnit reports a failure's location only in the message text, not as an attribute, so a failure it can't describe that way is listed without a link rather than pointed somewhere wrong.