paider / paider
A PHP-native AI coding agent.
Requires
- php: ^8.4
- laravel-zero/framework: ^12.0.2
- laravel/prompts: ^0.3.19
Requires (Dev)
- laravel/pint: ^1.25.1
- mockery/mockery: ^1.6.12
- pestphp/pest: ^3.8.4|^4.1.2
README
π Paider
A PHP-native AI coding agent β that lives inside your Laravel app. π€
Built on Laravel Zero Β· Laravel Prompts Β· Termwind Β· MCP PHP SDK (v0.2)
π¦ Status, honestly
Alpha. The commands work, the ledger reports real money, and it has talked to real models. What it has never done is drive an end-to-end edit in someone else's repo.
Built in public from commit one, wrong turns left in. Here is precisely what that means today:
| state | evidence | |
|---|---|---|
| π§± v0.1 command surface | β built | paider chat, commit, cost, config:provider, config:show all register and run |
| π§ six native tools | β built | read_file, write_file, patch_file, run_shell, git, artisan |
| ποΈ SQLite event log + cost ledger | β built | append-only, ledger is a pure projection; stored in .paider/ (gitignored locally) |
| π§ͺ test suite | β 175 passing, 630 assertions | hermetic by default; 3 live tests via vendor/bin/pest --group=live |
| π talking to a real LLM | β verified live | OpenRouter, Anthropic, xAI; cost ledger reconciles to provider usage |
| π¦ published on Packagist | β published | paider/paider at https://packagist.org/packages/paider/paider |
π¦ curl | sh installer |
β¬ not built | the binary is measured, the installer is not written |
| π·οΈ tagged release | β v0.1.0 | composer require paider/paider resolves without a stability flag |
Do not install this expecting a working agent. The wiring is real and tested; the last mile β an actual API key, an actual model, an actual edit landing in your repo β is unproven.
π What this is not
It is not the first PHP coding agent β neuron-core/maestro
got there first and its README says so correctly. It is not faster than a Go or Rust agent; PHP's
interpreter floor is 48.6ms against Python's 21.5ms and ripgrep's 3.7ms, and no amount of care
changes that. If raw startup is what you want, use something compiled.
π Honest comparison
Stars and dates pulled live from the GitHub API on 2026-08-02:
| π Paider | neuron-core/maestro |
Aider-AI/aider |
|
|---|---|---|---|
| language | PHP | PHP | Python |
| stars | unreleased | 38β | 47,886β |
| last pushed | active | 2026-06-19 | 2026-05-22 β οΈ |
| open issues | β | 0 | 1,770 |
| first PHP agent? | β no | β yes, and says so | n/a |
| shipping today? | β not yet | β yes | β οΈ stalled |
| named cost tiers | β 4, with a ledger | β | β main/weak/editor, unlabeled |
| open-weight presets | β 2 | β | β |
| commercial coupling | none | Inspector.dev SaaS | none |
Read that table honestly: Maestro ships today and Paider does not. Maestro is also 38 stars beside its own 2,038-star underlying SDK β so "PHP developers want an agent CLI in PHP" is unproven, not confirmed, even by the one entrant that exists. Aider proves the opposite risk: 48k stars mean nothing once the maintainer goes quiet.
What Paider bets on is the two columns nobody else fills in β named cost tiers with a checkable ledger, and an agent that lives inside your Laravel app.
π‘ What it is
Two bets.
1. An agent that lives inside your Laravel app knows things an external one has to be told.
laravel/mcp builds MCP servers; the PHP SDK consumes them. A Laravel application can be both
ends of the protocol at once. Shipped as a package, Paider turns your own models, jobs, queues
and domain logic into tools the agent can call β defined in the framework's idiom, not
hand-rolled JSON schemas. No Python or Go CLI can do that for a Laravel developer.
The full version of this β any MCP client driving Paider's tools β is v1.0, gated on the MCP PHP
SDK maturing past pre-1.0. The intended first step is ArtisanTool, reading route:list as
structured data instead of shell text when pointed at a Laravel repo.
β
ArtisanTool is built. One hardcoded call (php artisan route:list --json), not a general
Artisan passthrough β arbitrary code execution needs its own approval gate rather than a slot in
ShellTool. Registered only when an artisan file exists at the project root (detects Laravel apps).
See PLAN.md Β§ Sequencing.
2. You can see exactly where the money went.
Four tiers, named for what they are for:
| tier | job | why it matters |
|---|---|---|
orchestrator |
plans, decomposes, reviews | low volume, high value |
coder |
writes the diff | runs in a loop, latency compounds |
research |
reads docs, greps, summarises | high volume, low difficulty β where the money quietly goes |
fast |
commit messages, retries | trivial work at trivial cost |
Nobody else names a research tier. It is the one that ingests 50k tokens to extract 500, and paying orchestrator rates for it is how agent bills get absurd.
Because the tiers are named, Paider can account for them separately β and answer a question no other agent CLI can:
$ paider cost
tier calls in out spend share
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
orchestrator 14 61.2k 19.8k $0.801 84.9%
coder 203 1.4M 287.1k $0.079 8.4%
research 118 1.8M 34.6k $0.058 6.2%
fast 77 98.4k 12.2k $0.005 0.5%
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
session 3.36M 353.7k $0.943
97.8% of your tokens went through tiers costing 15.1% of your spend.
Same work on all-Opus 5: $25.64 Β· you saved $24.70
Modelled session, real command. β
paider costprints all of the above β every column, the ratio line and the all-Opus comparison β straight off the event log. The token volumes shown are a modelled session, but the arithmetic is the shipped code's, not a mockup's. Eachtier_callevent is priced at write time fromconfig/prices.phpby exact model id. A model with no entry there storescost_usdasNULL, not$0.00β those surface as unpriced calls naming the specific model, rather than silently undercounting the total.Two tests hold this block and the code together from opposite ends:
CostTableTestparses this table and recomputes it fromconfig/prices.php, andCostReadmeGoldenTestseeds the event log with these volumes, runs the real command, and asserts the output matches. The table cannot drift from the code in either direction without a failing test.
That last line is the product in one sentence. Most agent tools show you a total, if anything. Paider shows you the ratio β and the ratio is the whole argument for routing, once it's wired up.
It also keeps us honest. The 95.3% figure below is a modelled session; the ledger is what confirms or refutes it on real work. A cost claim you cannot check is marketing, and this one is checkable by the person paying.
The presets
Eleven ship in config/presets.php, every model ID and price verified
against the live OpenRouter catalogue. Modelled on a session planning 50k/20k and working 2M/300k:
| stack | cost |
|---|---|
| all Opus 5 | $18.25 |
| all Sonnet 5 | $7.30 |
default β Opus 5 to think, qwen3.7-flash to do |
$0.85 |
There is also an open-weight stack β kimi-k3 orchestrating with qwen3.7-flash on the
other three tiers, self-hostable end to end, for people who will not send their code to a US
frontier lab. As far as we can tell nobody in PHP ships a preset for them.
paider config:provider open # kimi-k3 + qwen3.7-flash, open weights paider config:provider balanced # opus-5 to think, qwen3.7-flash to do paider config:provider kimi # single-provider stacks for all the majors paider config:show # what am I actually running?
π³οΈ Why build it at all
Aider-AI/aider has 47,886 stars, 1,770 open issues, and no
commit since 2026-05-22. Its most-repeated issues are not feature requests β they are
#4613 "where is Paul?" and
#4648 "what is the intended future of Aider?",
closed not-planned. There are 4,805 forks and none has consolidated the userbase; the leading
one holds 0.8% of upstream's stars.
It died of abandoned stewardship, not a technical flaw. That is the thing worth designing
against, and it is why PLAN.md has a Non-goals section longer than its feature list.
π₯οΈ On the terminal UI
The fashionable agent CLIs render with Ink, React for the
terminal. It is good, and it is not obviously better than what PHP already has for this shape of
program. Symfony Console is twenty years mature; laravel/prompts does streaming output
(stream(), task()->partial()) and every interactive input, with non-TTY fallback built in;
Termwind does Tailwind-style layout for static output; and Collision renders errors better than
most things in any language.
The Windows caveat you will read about β "laravel/prompts is WSL-only" β does not apply here,
and that was measured, not assumed. Illuminate\Console\Command already calls
Prompt::fallbackWhen(windows_os()) and registers Symfony Question Helper fallbacks, so Laravel
Zero inherits a working Windows path for free. The streaming output side is byte-identical
under the fallback (all 47 lines of a captured run); only the interactive input prompts change,
from arrow-key selection to typed numbers. Windows Terminal is the documented baseline β legacy
cmd.exe mangles the box-drawing glyphs. Full measurement in DECISIONS.md Β§10.
Where Ink genuinely wins is a full-screen alternate-buffer app with many live reactive panes. A coding agent is mostly streaming text, a spinner, a diff and a confirm β and PHP is fine at those. If Paider ever needs a real reactive TUI, that is the moment to reconsider, not before.
ποΈ Architecture
Everything durable goes through one SQLite file. The cost ledger is not a balance anyone
increments β it is a projection replayed over an append-only event log, which is why
/undo and the audit trail come free rather than being features someone has to maintain.
you β ChatCommand β Session β Loop ββ¬β TierRouter β provider (Anthropic | OpenAI-compatible)
ββ Approval Gate β tools β PathGuard
β
events (append-only) β CostLedger
πΊοΈ Same thing as a diagram (renders on GitHub)
flowchart TB
User([π€ you]) -->|prompt| Chat[π¬ ChatCommand]
Chat --> Session[π§ Session]
Session --> Loop[π Loop]
Loop -->|picks a tier| Router{{ποΈ TierRouter}}
Router -->|orchestrator| P1[π AnthropicClient]
Router -->|coder Β· research Β· fast| P2[π OpenAiCompatibleClient]
Loop -->|proposes a tool call| Gate{π Approval Gate}
Gate -->|denied| Loop
Gate -->|allowed| Tools
subgraph Tools [π§ native tools]
direction LR
T1[read_file] ~~~ T2[write_file] ~~~ T3[patch_file]
T4[run_shell] ~~~ T5[git] ~~~ T6[artisan]
end
Tools -->|every path checked| Guard[π‘οΈ PathGuard]
Tools --> Log[(ποΈ events Β· append-only)]
P1 & P2 --> Log
Log -->|projection| Ledger[π° CostLedger]
style Gate fill:#f96,stroke:#333,color:#000
style Guard fill:#9cf,stroke:#333,color:#000
style Log fill:#cfc,stroke:#333,color:#000
Loading
π One turn, end to end
sequenceDiagram
autonumber
participant U as π€ you
participant L as π Loop
participant R as ποΈ TierRouter
participant M as π model
participant G as π Gate
participant T as π§ tool
participant E as ποΈ events
U->>L: "fix the failing test"
L->>R: which model for `orchestrator`?
R-->>L: anthropic/claude-opus-5
L->>M: prompt + tool contracts
M-->>L: fenced tool call
Note over L,M: text-fenced blocks, not native tool-calling β<br/>providers disagree on the format, qwen3.7-flash<br/>reports structured_outputs=false
L->>G: patch_file(app/Foo.php)
G->>U: show diff Β· allow once / session / deny
U-->>G: allow once
G->>T: execute
T->>T: stamp check β parse hunks β syntax gate
T-->>L: ToolResult
L->>E: append(tool_result, β¦)
E-->>L: uuid7
Note over E: append only β no update(),<br/>no delete(), anywhere in the class
Loading
π§ͺ Test suites β hermetic by default, live on demand
Two test runs, two purposes:
# Default: hermetic, no network, no cost # β safe to run on every commit, in CI, anywhere vendor/bin/pest # Live: real API calls, real spend (measured and reconciled) # costs money Β· requires credentials Β· read the ledger output vendor/bin/pest --group=live
Hermetic suite (vendor/bin/pest, 175 tests) β all provider interactions mocked via Guzzle;
proves self-consistency, zero cost. Excluded group: live. This is the number in the badge above;
the live suite is 3 more on top, not part of it.
Live suite (vendor/bin/pest --group=live, 3 tests) β real round-trips to api.openrouter.ai,
api.anthropic.com, and api.x.ai (xAI fallback when ANTHROPIC_API_KEY absent). Discovers
shape mismatches, usage-field placement, and actual token costs. Tests skip gracefully (no failure)
when credentials are absent, so CI stays green in sandboxes.
Environment variables for live suite:
OPENROUTER_API_KEYβ enables OpenRouter test (qwen/qwen3-max)ANTHROPIC_API_KEYβ enables Anthropic test (claude-opus-5-latest)XAI_API_KEYβ fallback when Anthropic key absent (Claude via xAI's Anthropic-format endpoint)
π Live test discoveries
Three real calls across two tiers; the cost ledger reconciles exactly:
- OpenRouter round-trip β
qwen3-maxresponds with usage fields where the parser expects them - Anthropic wire format β novel finding: grok-4 returns a
thinkingcontent block beforetext, andAnthropicClientfilters ontype === 'text'. Every hand-written fixture was text-only, so this filter had never been exercised against reality until now. β holds. - Cost ledger reconciliation β provider-reported token counts match our projection's totals to 1e-9; spend matches the price sheet exactly; tiers partition the session correctly.
β¨οΈ CLI reference
| command | what it does | state |
|---|---|---|
paider / paider chat π¬ |
interactive session rooted at the cwd | β built |
paider commit π |
stage everything, generate a message on the fast tier, commit | β built |
paider cost π° |
per-tier calls, tokens, and spend from the event log | β built |
paider config:provider <preset> ποΈ |
switch the active tier stack | β built |
paider config:show π |
show the active preset and the model per tier | β built |
In-session slash commands β aider's proven UX, not a Paider invention:
| slash | effect |
|---|---|
/add <file> β |
put a file in context (and stamp it for staleness detection) |
/drop <file> β |
take it back out |
/diff π |
show pending changes |
/undo β©οΈ |
roll back the last applied change |
/tier <name> <model> ποΈ |
override one tier for this session |
/quit π |
leave |
π The parts we were paranoid about
Every row below is a bug that was found and fixed by adversarial review, not a design someone got right first try:
| guard | what it stops |
|---|---|
π‘οΈ PathGuard |
.. traversal in a non-existent tail and an existing intermediate dir symlinked out of the project |
π Gate |
only ever caches a grant β there is no path that reads a cached deny as an allow |
| π approval bypass | the model's own tool-call input never contains the approval key β Loop deletes it before the gate runs, so a model trying {"approval":"allow-once"} cannot self-approve |
π§Ύ EventLog |
no update(), no delete(), anywhere β append-only is structural, not a comment |
π€« SecretsGuard |
redaction before anything reaches a model |
πΈ QwenPlanKeyGuard |
refuses an sk-sp- plan key paired with a PAYG base URL, which would silently bill you |
| π« strict JSON | six paths where a lenient decode turned a failed call into a successful-looking empty one |
β±οΈ ShellTool timeout |
SIGTERM then SIGKILL after 0.5s β proc_close() blocks until the child exits, so a trap '' TERM command ran the full 20s against a 1s timeout and reported exit 0 |
π patch_file + secrets |
creating a new .env/id_rsa skipped approval entirely, because stamp='__new_file__' needs no prior read β which is where the gate used to catch it |
| β result checking | paider commit returned SUCCESS when nothing was committed, and fed a SecretsGuard refusal to the model as though it were a diff |
π¬ Where these came from
Every row above was found by an adversarial review pass that read the code on disk rather than the author's summary, then re-verified against the committed result. Three are worth calling out because the tests were green the whole time:
- Approval gate bypass (critical).
Loop::dispatchArtisan()andLoop::dispatchShell()passed the model's own tool-call input straight through to the tool without scrubbing. If that input contained{"approval": "yes"}, it bypassed the gate entirely β the approval callback never ran.ArtisanToolaccepts any non-denyvalue, andLoop::systemInstruction()JSON-encodes the tool'sinputSchema()into the prompt, so the model was explicitly told the field name and values. A model reply of{"name":"artisan","input":{"approval":"allow-once"}}would run service providers with zero human approval: arbitrary code execution. Fixed byunset($input['approval'])in both dispatchers before the gate. Two regression tests now assert the gate runs and its answer wins when a model tries to self-approve. /addwas silently inert.Loopnever readSession::contextFiles(), so added files never reached the model β and without their sha256 the model could not supply the stamppatch_filerequires. The headline workflow did nothing, with no error.PathGuardhad two independent escapes, found by two different reviewers. The first was..in a non-existent tail. The second was an existing intermediate directory symlinked out of the project, which the first fix said nothing about.
π Read the thinking
- STORAGE.md β one SQLite file, no services. Why not Redis.
- EXTENSIONS.md β the eleven extensions that ship, and what was cut.
- PLAN.md β thesis, non-goals, v0.1 scope, architecture, milestones, risks.
- DECISIONS.md β how we got here, measured, with the wrong turns left in: recommending the wrong repo, picking a model off a spec sheet that a practitioner knew was a dead zone, and asserting a gap that turned out to be occupied.
π Measured, not estimated
Startup, one machine, medians:
| ripgrep (Rust) | 3.7ms |
| gh (Go) | 20.0ms |
| Python, bare | 21.5ms |
| PHP, bare | 48.6ms |
| Laravel Zero, lean ini | 95.9ms |
| cecli (1,548 modules) | ~710ms |
π¦ Distribution
Two channels, because there are exactly two users:
composer require paider/paider # inside your Laravel app β this is the thesis composer global require paider/paider # as a standalone CLI curl -fsSL paider.dev/install | sh # planned: standalone binary, not built yet
Tagged releases start at v0.1.0, so a bare composer require resolves under Composer's default
minimum-stability: stable. Track the branch with paider/paider:dev-main if you want unreleased
work β that is where the sharp edges live.
The package is non-negotiable: an agent that turns your models and jobs into tools has to be a dependency of your app, and a compiled binary cannot be one.
The binary is planned to be a FrankenPHP embed (11,263β
, Go,
built on Caddy), which produces a self-executable with PHP inside and
supports CLI β ./my-app php-cli bin/console β not just
HTTP. It selects extensions from composer.json, so the shipped tool never inherits a user's dev
ini. That matters: 76 extensions on the author's machine cost 94ms of a 143ms startup.
β Measured 2026-08-02, round 2 β trimmed and built, both risks resolved
Round 1 measured the stock off-the-shelf binary and could only clear the cold-start risk,
leaving size conditional. Round 2 actually built the trimmed binary natively β and it revises
round 1's cold-start conclusion, not just its size one. Full numbers:
DECISIONS.md Β§9.
| round 1 (stock, 77 ext) | round 2 (trimmed, 11 ext) | |
|---|---|---|
| Size | 178MB | 111.3MB (106.2 MiB) β β37.5% |
Compressed (zstd -19) |
60.4MB | 40.6MB |
| Cold start vs. lean-ini PHP | ~23% slower | parity (1.01x, i.e. no measurable penalty) |
| Cold start vs. stock binary | β | 1.20x faster |
Cold start: the penalty is gone, not just smaller. Round 1's "~23% slower than lean-ini PHP" is superseded β that number came from the stock binary dynamically initialising 66 unwanted extensions on every invocation. Trim to the eleven Paider actually needs and the penalty disappears: 94.8ms Β±1.3ms vs. 95.9ms Β±1.3ms for lean-ini system PHP, a statistical tie. Say it as "the cold-start penalty is eliminated," not "FrankenPHP is faster than PHP" β it isn't, it's even.
Size: 111MB on disk, honestly stated β but 40.6MB is what an installer downloads. That lands
right alongside the ~40MB Go binaries competing agents ship, which makes the curl | sh story
viable. Getting there required fixing a real bug first: the "documented nine" extensions produced
a binary that could not boot β laravel-zero/framework calls Phar::running()
unconditionally and needs ext-phar present, undeclared in its own composer.json. Paider's
required set is eleven, not nine (adding phar and filter cost +283KB). See
EXTENSIONS.md for the trap and the full extension table.
Full round 2 hyperfine table (3 warmup + 30 timed runs, -N)
| command | mean | std dev |
|---|---|---|
php -n application --version (lean-ini system PHP 8.5.8) |
95.9ms | Β±1.3ms |
php application --version (real Homebrew ini, 73 ext) |
192.9ms | Β±1.4ms |
| stock frankenphp, 77 ext, 178MB | 113.5ms | Β±1.4ms |
| trimmed frankenphp, 11 ext, 111MB | 94.8ms | Β±1.3ms |
The maintainer's 20β30MB estimate was not reached, and now we know why: build-static.sh
always links the full Caddy server and Go HTTP stack, even for a binary only ever invoked as
php-cli β there's no CLI-only mode in the script. Not claimed impossible in general, just not
what the supported build path produces. Whether a Caddy-free CLI-only build could get closer is
open β see PLAN.md β but it's a nice-to-have now, not a blocker.
Build cost is cheap. Built natively (not Docker β the Docker static-builder emits Linux
binaries only) in ~7 minutes, PHP 8.5.9 / Caddy v2.11.4 / Go 1.26.5. build-static.sh cannot
cross-compile, so shipping is still a CI matrix (ubuntu-latest, ubuntu-24.04-arm,
macos-latest, macos-13), but 7 minutes/platform is cheap enough for that matrix to be routine.
A Windows binary does exist (frankenphp-windows-x86_64), and the laravel/prompts half of
the Windows question is resolved β Laravel's own ConfiguresPrompts already handles it, and
the streaming path is unchanged. See DECISIONS.md Β§10. Windows is shippable.
Functional check under the trimmed binary, for the record: application list renders correctly,
pdo_sqlite round-trips an in-memory DB (see STORAGE.md), stream_isatty()
behaves correctly under a non-TTY pipe, and PHP_VERSION reports 8.5.9 β comfortably above the
^8.4 floor. Full writeup: DECISIONS.md Β§9.
The distribution decision is now confirmed on both axes β cold start and size β where round 1
could only confirm one. curl -fsSL paider.dev/install | sh above is still planned, not
built; the binary math behind it now checks out.
No PHAR. It needs PHP installed but is not a composer dependency, so it serves neither user better than the two above. A third channel is maintenance forever for an audience of nobody.
No Docker. Container start would eat the entire startup budget.
πΊοΈ Roadmap
π Milestone flow (renders on GitHub)
flowchart LR
V01["π§± v0.1<br/>commands Β· tools Β· ledger"] --> V02["π v0.2<br/>MCP client Β· agent roster"]
V02 --> V10["ποΈ v1.0<br/>Paider as an MCP server"]
style V01 fill:#cfc,stroke:#2a2,color:#000
style V02 fill:#ffd,stroke:#aa2,color:#000
style V10 fill:#eee,stroke:#999,color:#000
Loading
| milestone | scope | state |
|---|---|---|
| v0.1 | 5 commands, 6 tools, approval gate, event log, cost ledger, tier router, CI pipeline, ~94.8ms cold start | π¨ in progress |
| v0.2 | mcp/sdk client, paider run --yes, repo-map on the research tier, test-feedback loop |
β¬ planned |
| v1.0 | MCP server mode β external clients drive Paider's tools; published semver policy | β¬ planned |
β Why is v0.1 still π¨ when the code is written and 175 tests pass?
Because PLAN.md wrote v0.1's definition of done before the code existed, and
grading against it honestly leaves one box unticked:
| v0.1 definition-of-done | state |
|---|---|
| the four commands | β built |
the five native tools + ArtisanTool |
β six built |
sk-sp- key/base-URL guard |
β built |
diff-apply staleness, syntax gate, /undo, secrets guard |
β built |
| honest comparison table vs Maestro | β added above |
| live provider round-trips | β 3 tests, ledger reconciles |
| published on Packagist | β
published β paider/paider, tagged v0.1.0 |
| CI pipeline with hermetic + live suites | β
shipped β .github/workflows/tests.yml runs on PR, --prefer-lowest catches version leaks |
| end-to-end on a real repo with a real API key | β¬ never attempted |
The one remaining box β running a full session against a real repo with a real API key and watching an edit land β is the last blocker to shipping v0.1. Everything else is done. The rule
in this repo is that a green checkbox is a promise a grep or a test run can keep β eight
checked above are testable / grepable; the unchecked one requires end-to-end human verification.
π License
Apache-2.0 β see the full text, which is byte-for-byte the official one
(md5 3b83ef96387f14655fc854ddc3c6bd57).
Dependency licenses audited and compatible: 93 MIT Β· 26 BSD-3-Clause Β· 1 Apache-2.0. All permissive, no copyleft.
Written in PHP on purpose. π
Not because PHP is fast β it isn't, and DECISIONS.md Β§3 says so with numbers β
but because an agent that lives inside your Laravel app knows things an external one has to be told.
Every β above is backed by a passing test. Every β¬ is honest about not existing yet.