Ask a coding agent what happens when an issue is deleted in a Laravel application and it will start reading files. It knows Laravel conventions, so it finds the controller, the model and probably the policy. It does not know that an Artisan command dispatches a queued job that removes old closed issues, that the job is called ArchiveClosedIssues but deletes rows, or which of the nineteen tests would catch a change to the policy. That knowledge sits in the team's heads, and the agent fills the gap by guessing.
I call codebases like this telepathic: to understand them, you need knowledge nobody wrote down. The agent is not the problem. The codebase asks it to read minds.
Laravel Necromancer is the package I built to make that knowledge explicit. It boots the application, inventories its artifacts into a manifest, and every other command works from that manifest: context files for agents, a readability score, impact analysis, CI checks. In this article I run it on Laraboard, a small issue tracker I use as a demo app, with projects, issues, milestones, policies, events, jobs and Livewire components.
Installing
Necromancer is a development dependency:
1composer require --dev robertogallea/laravel-necromancer
2php artisan vendor:publish --tag=necromancer-config
Publishing the configuration is optional. The current version is 2.3.0 and it requires PHP 8.3 or later and Laravel 13; the route metadata shown later needs Laravel 13.17 or later.
Scanning the application
Everything starts from one command:
1php artisan necromancer:scan
It writes necromancer.json, an inventory of 21 artifact types: routes, controllers, models, jobs, events, listeners, policies, form requests, enums, observers, commands, middleware, Livewire components, tests and more. On Laraboard it found 20 routes, 16 controllers, 7 models, 4 policies, 2 jobs and 19 tests. This is the entry for ArchiveClosedIssues:
1{
2 "id": "jobs:App\\Jobs\\ArchiveClosedIssues",
3 "class": "App\\Jobs\\ArchiveClosedIssues",
4 "queue": "maintenance",
5 "connection": "redis",
6 "tries": 3,
7 "timeout": 300,
8 "backoff": [30, 60, 120],
9 "max_exceptions": 2,
10 "source": {
11 "file": "app/Jobs/ArchiveClosedIssues.php",
12 "line": 24,
13 "line_end": 38,
14 "hash": "39496608dd7d077d102fab543158db29"
15 }
16}
The queue settings come from the #[Queue], #[Tries] and #[Backoff] attributes on the class. The scan does not just parse source files: it runs inside the booted application, so it sees what Laravel actually resolved, including container bindings, middleware groups and the authorization attached to each route.
The source block is what makes the manifest trustworthy. Every artifact points to the file and lines it came from, so an agent can cite its sources instead of paraphrasing from memory, and you can check the citation. The hash tells you when an entry no longer matches the code it describes.
The scan is also deterministic. It needs no API key and calls no model: the same code always produces the same inventory.
Context for the agent
The manifest is for machines. generate turns it into Markdown an agent reads:
1php artisan necromancer:generate
Without Laravel Boost it writes NECROMANCER.md in the project root. Laraboard has Boost installed, so Necromancer writes two files and lets Boost compose them: a compact guideline in .ai/guidelines/necromancer.md, always loaded, and the full context as a Boost skill in .ai/skills/necromancer/SKILL.md, which the agent loads when it needs detail. A php artisan boost:update puts both in place. The compact guideline summarizes the application, for example its models:
1| Model | Table | Relationships |
2|---|---|---|
3| Comment | comments | belongsTo Issue, belongsTo User |
4| Issue | issues | belongsTo User, hasMany Comment, belongsToMany Label, belongsTo Milestone, belongsTo Project |
5| Milestone | milestones | hasMany Issue, belongsTo Project |
The skill has the full tables: every route with its name, controller, middleware, authorization and source line, then controllers, models, jobs, events, policies and tests.
If you would rather have the agent ask than read, install laravel/mcp too. Necromancer then registers an MCP server in .mcp.json with eight read-only tools, such as query_routes, search_artifacts, get_impact and get_affected_tests, so the agent queries the manifest on demand.
How readable is the application?
doctor scores how much of the application an agent can understand without guessing:
1php artisan necromancer:doctor
1Score: 86%
2
3Route Clarity █████████ 90% (17/20 named · 19/20 controller-backed)
4Model Expressiveness ██████████ 100% (7/7 casts · 7/7 fillable · 7/7 relationships)
5Authorization Coverage ███████ 69% (4/7 policies · 9/11 write routes with auth)
6Validation Coverage ████████ 75% (6/8 write routes with FormRequest)
7Async Clarity █████████ 94% (2/2 jobs configured · 4/4 events with listeners)
8Codebase Vocabulary ██████████ 100% (3/3 commands described · 4/4 backed enums)
9Test Presence ████████ 75% (7/7 models · 1/2 jobs)
Each dimension counts something concrete. An unnamed route is harder to refer to, a write route without a FormRequest hides its validation rules in a controller, a model without casts leaves the agent guessing types. necromancer:audit lists the individual findings with file and line. On Laraboard one of them is POST issues/{issue}/close, a route without a name.
Saying what the code cannot
Some knowledge is not in the code at all. Look at the job again:
1public function handle(): void
2{
3 Issue::where('status', IssueStatus::Closed)
4 ->where('updated_at', '<', now()->subDays($this->olderThanDays))
5 ->delete();
6}
The name says archive, the code deletes. Since Issue uses soft deletes the rows are recoverable, but an agent asked to change this job has no way to know whether the team thinks of it as a harmless cleanup or as something to touch carefully. The #[Necromancer] attribute lets you say it:
1use LaravelNecromancer\Attributes\Necromancer;
2use LaravelNecromancer\Metadata\Risk;
3
4#[Necromancer(
5 domain: 'issues',
6 capability: 'issues.archive',
7 summary: 'Soft-deletes closed issues not updated in the last 90 days.',
8 risk: Risk::High,
9)]
10#[Queue('maintenance')]
11// ...
12class ArchiveClosedIssues implements ShouldQueue
The attribute also works on controllers, at class level for defaults and on methods to override them. Routes get the same through a macro:
1Route::delete('/issues/{issue}', [IssueController::class, 'destroy'])
2 ->name('issues.destroy')
3 ->withNecromancer(
4 domain: 'issues',
5 capability: 'issues.delete',
6 risk: 'high',
7 );
On the next scan these values land in the artifact's annotations, and the generated context shows them next to the artifact: as Domain, Risk, External Services and ADR columns in the routes table, as an annotation line for classes. Closures, gates, tests and scheduled tasks cannot carry an attribute, so the configuration file accepts annotations keyed by artifact ID.
What does a change reach?
The manifest also records typed relationships between artifacts: a model is authorized_by a policy, observed_by an observer, tested_by a test. impact walks them:
1php artisan necromancer:impact "App\Policies\IssuePolicy"
1Impact of App\Policies\IssuePolicy (policies:App\Policies\IssuePolicy), depth 1
2
3Depth 1
4 models
5 App\Models\Issue authorized_by
6 tests
7 tests/Feature/PoliciesTest.php tested_by
affected-tests follows the same graph to pick the tests worth running after a change. It reads changed files from standard input, one per line, so in practice you pipe git diff --name-only into it. Here the only changed file is the policy:
1echo app/Policies/IssuePolicy.php | php artisan necromancer:affected-tests --stdin
1Affected tests for 1 changed artifact(s), depth 2
2
3Directly affected
4 tests/Feature/PoliciesTest.php via App\Policies\IssuePolicy (namespace match)
5
6Indirectly affected
7 tests/Feature/EventsTest.php via App\Models\Issue (reference)
8 tests/Feature/Jobs/ArchiveClosedIssuesTest.php via App\Models\Issue (reference)
9 tests/Feature/ListenersTest.php via App\Models\Issue (reference)
10 tests/Feature/Livewire/IssueListTest.php via App\Models\Issue (reference)
11 tests/Feature/ModelsTest.php via App\Models\Issue (namespace match)
A change to IssuePolicy reaches 6 of the 19 tests, and the reason for each is printed next to it. With --paths the output is a plain list of files you can pass to Pest or PHPUnit. Like the scan, none of this calls a model.
Keeping it in CI
A manifest is useful only while it matches the code, and a readability score only while someone looks at it. Three flags turn them into checks:
1php artisan necromancer:scan --diff --fail-on-drift
2php artisan necromancer:audit --fail-on=error
3php artisan necromancer:doctor --min-score=80
The first fails when the committed manifest no longer matches the application, the second when the audit finds errors, the third when the score drops below a threshold. None of them needs secrets: Necromancer never reads .env or configuration values, so nothing sensitive ends up in the manifest or in the context.
Why not a hand-written AGENTS.md?
A hand-written AGENTS.md or CLAUDE.md is where most teams start, and it is the right place for conventions and decisions. As an inventory, though, it goes stale on the first refactoring, and nothing tells you when it does. Letting the agent explore with grep keeps it current, but the agent pays for the same discovery on every task, and source files do not show what the booted application resolves: bindings, middleware groups, attributes.
Necromancer does not replace Laravel Boost either. Boost gives the agent Laravel's documentation, guidelines and tools; Necromancer gives it the inventory of this particular application, and with Boost installed it writes its output where Boost picks it up.
On Laraboard, a preliminary pilot gave answers 18 percentage points more accurate than a hand-written AGENTS.md, but with 4% of answers hallucinated against none and roughly five times the prompt tokens, on one application and without a variance estimate. That is a reason to measure, not a result to quote: necromancer:benchmark runs the same comparison on your own application, and I discussed why such numbers need care in Without evals, every change is a guess.
Beyond the basics
More commands build on the manifest. diff compares manifests across branches, graph writes the relationships as JSON and as an HTML page, and okf exports a Knowledge Bundle. With laravel/ai installed, ask answers questions about the application from the manifest, prompt assembles a source-grounded prompt, infer drafts ADRs from code and tests, and diff --review adds an AI review of the architectural changes.
To try it on your application, start with composer require --dev robertogallea/laravel-necromancer and a scan. The code is on GitHub and the project page collects the links. I will talk about AI readability at the International PHP Conference in Munich on 27 October, in Less Telepathic Codebases. If you want the next articles on Necromancer by email, subscribe to the newsletter.
Get the next posts by email
An occasional newsletter with my new Posts, with one-click unsubscribe. How I handle your address.