PHPGraph MCP

🐘 🏠 🍎 πŸͺŸ 🐧 - Knowledge graph of any PHP codebase built by static analysis (no LLM, no network): calls, routes, message buses, DDD layers and microservice contracts, with confidence levels, plus impact analysis and feature outlines for coding agents.

Documentation

phpgraph turns a PHP codebase into a knowledge graph of domains, handlers, repositories, controllers and adapters, served to AI agents through a CLI and an MCP server

phpgraph

A knowledge graph of your PHP codebase, for AI coding agents.
Static analysis served over MCP to Sherpa, Claude Code, Codex, Cursor and any MCP client.
No LLM, no network, no embeddings: deterministic facts, each with a confidence level.

CI Packagist version Downloads PHP 8.2+ License: PolyForm Shield 1.0.0

English Β· FranΓ§ais


Why phpgraph?

An AI agent dropped into an unfamiliar PHP project reads files one by one and greps for names. It misses what matters most in a real codebase: who calls what, which handler receives a message, which controller serves a route, what breaks if a method changes, and which layer depends on which.

phpgraph parses the whole project once (with nikic/php-parser) and answers those questions from a graph:

  • Deterministic facts, not guesses. Every relation is EXTRACTED (read in the code), INFERRED (resolved from declared types) or AMBIGUOUS (a guess, labelled as such).
  • It says what it does not know. Unresolved calls, missing vendor/, messages without handlers: the gaps are reported, so the agent knows when to read the code itself.
  • Built for DDD and hexagonal architecture, then microservices, then PHP in general: Symfony, Laravel, Ecotone, PrestaShop, WordPress, or plain PHP.
  • Private by design. Your code never leaves your machine, and phpgraph never executes it: it only parses it.

Features

  • Call graph with type inference: return types, chained calls ($a->b()->c()), local variables, docblocks (@return, @var), and signatures of your vendor/ dependencies, so chains go through Doctrine, Symfony or Laravel APIs.
  • Architecture: DDD and hexagonal layers, bounded contexts, layer rules checked in CI (phpgraph check), and impact analysis (impact_of): what depends on a class or method, and which tests to run.
  • Messages and events: sender β†’ message β†’ handler for Symfony Messenger, Laravel jobs and events, Ecotone, PrestaShop CQRS, WordPress hooks, domain events and your own buses, from attributes, service configuration (YAML, XML, PHP) or code shape.
  • HTTP: routes (Symfony attributes, YAML and PHP routing files, API Platform resources, Laravel route files, controllers named by container service id) linked to controllers, and HTTP calls linked to the routes they reach. A route whose controller class exists neither in the project nor in vendor/ is reported with its routing file.
  • Dependency injection: what the Symfony container configuration injects beyond constructor types, every service of a tag (tagged_iterator, #[AutowireIterator]) or a service named by id, from PHP, YAML or XML configuration, including helpers called with literal arguments (Wiring::wire($services, 'sales', ...), evaluated, never run): a validator is linked to the rules it receives. A rule declaring what it accepts (supports(object $input): bool { return $input instanceof CreateEstimateQuery; }, also canHandle, accepts…) is kept out of the use cases passing another input to the shared pipeline, the classes the input's properties hold included. An input typed by an interface is any project class implementing it and the parameter types it is passed on to (QueryInterface $query given to setUp(CreateEstimateQueryInterface $query): a CreateEstimateQuery); a guard it cannot read, or a rule running for some of these classes only, is kept, marked supports() not resolved.
  • Microservices: one id space per service, and links between services through their contracts: shared message classes, routing keys, HTTP routes.
  • API schemas: the operations of OpenAPI (Swagger) documents linked to the routes implementing them, or, for a document of another API, to the HTTP calls of its client; the rpcs of protobuf services linked to the classes implementing their generated interface. What is documented and not implemented, and the routes no document describes, are reported. impact lists the operations behind a change (the contract its clients rely on), outline those a feature serves.
  • Zero-configuration MCP server: built on first use, rebuilt incrementally when the code changes (about 3 s per edit on an 8,400-file project).

Quick start

1. Install. A single executable, no PHP needed (Linux and macOS on x86_64 and ARM, Windows on x86_64):

curl -fsSL https://raw.githubusercontent.com/hbenabdallah/phpgraph/main/install.sh | sh                  # latest release
curl -fsSL https://raw.githubusercontent.com/hbenabdallah/phpgraph/main/install.sh | sh -s -- 0.2.0     # a given release
curl -fsSL https://raw.githubusercontent.com/hbenabdallah/phpgraph/main/install.sh | sh -s -- dev-main  # the main branch
irm https://raw.githubusercontent.com/hbenabdallah/phpgraph/main/install.ps1 | iex                       # Windows
$env:PHPGRAPH_VERSION = "dev-main"; irm https://raw.githubusercontent.com/hbenabdallah/phpgraph/main/install.ps1 | iex

The script downloads the binary, checks its SHA-256 and puts it in ~/.local/bin (Windows: %LOCALAPPDATA%\Programs\phpgraph, added to the PATH); PHPGRAPH_INSTALL_DIR changes the directory. Run it again to update. dev-main is rebuilt on every push to main (the edge pre-release). The binary is a static PHP runtime with phpgraph inside, built with static-php-cli.

With PHP 8.2+ installed, or with Docker:

composer require --dev hbenabdallah/phpgraph       # or: composer global require hbenabdallah/phpgraph
curl -LO https://github.com/hbenabdallah/phpgraph/releases/latest/download/phpgraph.phar
docker pull ghcr.io/hbenabdallah/phpgraph          # nothing else to install

2. Connect your agent. mcp-config prints a ready-to-paste configuration with the paths filled in (vendor/bin/phpgraph when installed with Composer):

phpgraph mcp-config sherpa    # Sherpa: ~/.config/sherpa/mcp.json, one declaration for every project
phpgraph mcp-config claude    # Claude Code: a `claude mcp add` command, or .mcp.json
phpgraph mcp-config codex     # Codex: ~/.codex/config.toml
phpgraph mcp-config cursor    # Cursor: .cursor/mcp.json, shareable with the team
phpgraph mcp-config json      # any other MCP client over stdio

With Sherpa, a terminal coding agent, one declaration in ~/.config/sherpa/mcp.json serves every project: Sherpa starts its MCP servers from the project it runs in.

{
  "mcpServers": {
    "phpgraph": { "command": "/home/you/.local/bin/phpgraph", "args": ["serve"] }
  }
}

With Claude Code (absolute paths, as mcp-config prints them):

claude mcp add phpgraph -- /home/you/.local/bin/phpgraph serve /path/to/project

3. Ask your agent something like "Give me an overview of this project", "How does an order get placed?" or "What breaks if I change OrderRepository::save?". The graph is built on the first call, then kept up to date.

MCP tools

ToolUse it to
overviewStart here: Composer stack, namespace tree with layers, bounded contexts, layer-rule violations, messages, HTTP routes (each with method, path, controller and file up to 30, else grouped by path prefix), services, and what the graph cannot see.
query_graphFind the code about a topic when you do not know the class names, by the words of their names (validated finds Validator), with the classes of the feature around the ones it names. A question about routes ("mooc courses routes", "GET /courses") returns the matching routes and their controllers. The families of classes around the answer are counted by folder (ContextRuleInterface: 34 in …), injected lists once (ContextValidator receives 34 ContextRuleInterface).
outlineBefore explaining a feature: its structural outline, enough to read only the few files with non-obvious logic. The classes it is made of, by namespace and layer, with the first sentence of their docblock, their public signatures, constants and enum cases; the interfaces and base classes around it with their implementations by module (0 stated); the routes running into it, chain by chain, through the closures it runs; behaviour read in the bodies (guarded throws, early returns, branches on constants, loop caps, state compared before and after); the container wiring with service ids; its users, tests counted by module, what no test touches and what nothing uses. About 15 KB; a list cut short names the section that has it all (section behaviour, 1 to 9 KB).
get_nodeRead one class, method, route or channel with all its relations; tests using it are counted by module.
get_neighborsSee what uses a node (in) or what it depends on (out).
impact_ofBefore a change: every class that depends on a class or method, nearest first, with the lines of the calls; callers through the interface it implements; code depending on the state it writes; the routes reaching it, with each chain; and the tests to run, found through test helpers. It flags the classes no test touches and those nothing in the application uses.
shortest_pathSee how two pieces of code are connected.
god_nodesFind the hubs most of the code depends on.

The server tells the agent to call overview first. A path, as an agent sees it, on php-ddd-example:

Shortest path (4 hops):
  PUT /courses/{id} --handled_by--> CoursesPutController::__invoke()
    --dispatches--> CreateCourseCommand --handled_by--> CreateCourseCommandHandler::__invoke()
    --calls--> CourseCreator::__invoke()

Command line

phpgraph build [path]                        # phpgraph-out/graph.json and GRAPH_REPORT.md
phpgraph overview                            # stack, structure and gaps
phpgraph impact "OrderRepository::save"      # what depends on it, and the tests to run (--all, --section tests, --format json)
phpgraph check                               # layer rules, for CI: exit code 1 on a new violation
phpgraph explain "PlaceOrderHandler"         # a node and its relations (-d in|out)
phpgraph path "StockChecker" "DbalOrderRepository"
phpgraph query "how is stock checked"
phpgraph outline "notification validation"   # the outline of a feature (--section flow, --format json)
phpgraph serve [path]                        # the MCP server, over stdio
phpgraph mcp-config claude|codex|cursor|json [--docker image]

build options: -e to exclude paths (repeatable), --no-vendor not to read dependencies, --no-cache to parse every file again. vendor, node_modules, var, .git and the files ignored by the project's own .gitignore files are skipped; a git repository above the project never applies its rules, so a project copied into an ignored directory is still read. When the directory holds PHP files but every one is excluded, build fails and says so, and every MCP answer starts with that warning instead of serving an empty graph. Memory is unlimited by default; PHPGRAPH_MEMORY_LIMIT=2G sets a limit. With PHP installed, opcache and its JIT make builds about 20% faster: php -d opcache.enable_cli=1 -d opcache.jit=tracing -d opcache.jit_buffer_size=128M vendor/bin/phpgraph build (the Docker image and the commands printed by mcp-config already use them).

What the graph contains

RelationFrom β†’ to
defines, importsfile β†’ class, use statement
extends, implements, uses_traitclass β†’ parent, interface, trait
has_method, overridesclass β†’ method, method β†’ the method it overrides
instantiates, referencesnew Foo(); parameter, return and property types, catch, instanceof, constants, attributes
callsmethod β†’ resolved method, with the lines of the call sites; a method running a closure it receives ($apply(...)) calls what the closure calls, INFERRED, via closure of <the method writing it>
dispatches, handled_bysender β†’ message or channel β†’ handler; route β†’ controller; class tested by a supports() method β†’ that strategy
contracta message class sent by one service β†’ the same class handled by another
requestsHTTP call β†’ the route it reaches, in the same service or another, or the operation of another API's OpenAPI document
implemented_byAPI schema operation (operation:) β†’ the route with its method and path (OpenAPI), or the method of a class implementing the generated service (protobuf)
receivesservice β†’ each service the container configuration injects into it (a tag, an id, the decorated service); via says which: service sales.pipeline for one of several services of a class
reads_state_ofmethod β†’ a method of the same class changing a property it reads, outside the constructor (hasErrors() β†’ add()), INFERRED

Every relation carries a confidence level:

LevelMeaning
EXTRACTEDRead in the code of one file, or in the configuration (attribute, service tag, route file).
INFERREDResolved across files from declared types: parameters, properties, $this, inheritance, return types, shape of the code.
AMBIGUOUSA guess, for example the only method of the project with that name, called on a receiver of unknown type.
Frameworks and conventions recognised
Handlers and listenersMessages sentHTTP
Symfony#[AsMessageHandler], #[AsEventListener], tags messenger.message_handler and kernel.event_listener (YAML, XML, PHP, _instanceof), getSubscribedEvents(), addListener()MessageBusInterface, EventDispatcherInterface, named events#[Route] (paths in class constants too), YAML and PHP routing files, controllers named by service id; API Platform #[ApiResource] and operation attributes, handled by their provider, processor or controller
Laravel$listen, Event::listen(), jobs (ShouldQueue, Dispatchable)event(), dispatch(), facades, Job::dispatch()Route::get/post/…, match, resource, groups with prefix, routes/api.php; Http:: client
Ecotone#[CommandHandler], #[EventHandler], #[QueryHandler], routing keysCommandBus, EventBus, DistributedBus, sendWithRouting()
PrestaShop#[AsCommandHandler], #[AsQueryHandler]CommandBusInterface::handle()YAML routing
WordPressadd_action(), add_filter()do_action(), apply_filters()
Your own codehandler attributes and tags recognised by name, __invoke/handle(Message $m), *Handler classes*Bus, *Dispatcher, *Publisher types, aggregates' recordThat(), your bus wrappersGuzzle, Symfony HttpClient, PSR-18
Architecture: layers, bounded contexts, rules in CI

The layer of a class is read from its namespace: the first explicit segment (Domain, Application, Infrastructure, UI, Presentation, Port, Adapter, UseCase), or else the last generic one (Model, Entity, Controller, Http, Persistence…). The bounded context is read before the layer (CodelyTv\Mooc\Courses\Domain) or after it when the project puts the layer first (Core\Domain\Product).

phpgraph check applies the default rules of a layered architecture to application code: domain must not depend on application, infrastructure or interface; application and port must not depend on infrastructure or interface. On a project that already has violations:

phpgraph check --generate-baseline   # accept today's violations in phpgraph-baseline.json
phpgraph check                       # fail on new ones only
Microservices

A repository holding two or more independent Composer applications is split into services. Packages of a monorepo stay in their project: a package the root autoloads, declares as a path repository or replaces is a part, not a service; a library the services require (a path repository, or its package name) is their common code. Ids then carry their service, billing@App\Domain\Order, and a name never resolves into another service: services meet through contracts.

  • the same message class sent by one service and handled by another: contract;
  • a routing key (sendWithRouting('order.place', …), #[CommandHandler('ticket.create')], a named event): a channel: node shared by all services;
  • an HTTP call to a route of another service: requests. A call whose path matches a route declared for other HTTP methods only is reported: it would fail as written;
  • an API schema: the operation: nodes of an OpenAPI document (GET /api/hotels/{hotel}) or of a protobuf service (CinemaService.Reserve), implemented_by the routes or classes serving them. A document at the root of the repository describes every service (an API gateway), one in a service that service only.

An optional phpgraph.yaml names the services when detection does not fit:

services:
  - services/billing
  - services/shipping
How it works
  1. Every PHP file is parsed into facts: declarations, calls with the expression typing their receiver, message sends and handlers, routes, HTTP calls.
  2. The builder resolves those facts across files: types through inheritance and return types, vendor/ signatures read on demand from Composer's metadata (parsed, never executed), service definitions from the container configuration.
  3. The graph is saved as phpgraph-out/graph.json, with GRAPH_REPORT.md for humans.

Rebuilds are incremental: a file's extraction is cached by content, and the MCP server re-resolves only the calls of changed files when no declaration changed anywhere. The result is identical to a full build.

Measured on real projects

phpgraph is developed against a corpus of open-source projects pinned to a commit, and every change is judged on their numbers (composer corpus:measure). Application code only; receiver typed is the share of method calls whose receiver type phpgraph could determine.

ProjectKindPHP filesBuildReceiver typed
php-ddd-exampleDDD, CQRS3040.4 s98.9 %
SyliusSymfony e-commerce4,9466.5 s96.9 %
Akeneo PIMhexagonal, bounded contexts8,41610.3 s90.4 %
PrestaShopCQRS and legacy7,85010.0 s96.1 %
Ecotone quickstart47 services, messaging5830.6 s94.4 %
BookStackLaravel1,5132.8 s96.2 %
WordPressno framework, hooks1,8995.3 s93.6 %

Four microservices projects complete the corpus: two Laravel services over RabbitMQ, linked by 4 message contracts; four Laravel services over HTTP, where phpgraph found a POST sent to a GET-only route; Spiral's ticket booking, services calling each other through gRPC services declared in .proto files; and StayHub, seven Laravel services behind an API gateway described by an OpenAPI file. The last two measure API schemas as contracts: all 45 operations of StayHub's document and all 14 rpcs of ticket-booking are linked to what implements them. Zero parse failures on about 26,600 files. The details per project, including messages, routes and services, are in corpus/RESULTS.md.

With an agent

Measured on a private 4,800-file Symfony/DDD project, 3 runs per setup, the same agent (Sherpa) with and without phpgraph 1.0.2:

Kind of questionWith phpgraphQuality
Impact or refactoring (impact_of)βˆ’42 % tokens, βˆ’50 % time, βˆ’59 % tool callssame or better
Explaining a feature (outline)βˆ’25 % tokens, βˆ’34 % time, βˆ’49 % tool calls; βˆ’31 % tokens with the default outlinesame

The ranges of the runs with and without phpgraph do not overlap.

Compared with other code graphs

Sylius (the corpus commit), October 2026, without any LLM: phpgraph 1.0.2, codebase-memory-mcp 0.11.0 and graphify 0.9.76 (--code-only), on the same machine, in Docker. The calls were compared site by site (file, line, method), and samples of the differences were checked in the code.

phpgraphcodebase-memory-mcpgraphify
Build10 s17 s32 s
Call sites linked to a project method22,87032,2744,005
of which resolved from types22,7474,314β€”
Callers of OrderItemQuantityModifierInterface::modify() found (17 in the code)17, no false one11, 2 false0, 1 false
Messenger handlers linked to their message20600
Container injections, routes1,360, 1880, 1 (false)0, 0
  • codebase-memory-mcp agrees with phpgraph on 99.7 % of the calls it resolves from types. The rest of its edges are guessed from the method name (confidence 0.04 to 0.44): where both link the same call, they disagree on 9,400 sites, and its 11,300 extra edges are mostly calls into vendor/ attached to a project method of the same name (19 of 20 checked).
  • graphify links $this->other->foo() to foo() of the current class: right for $this->foo(), wrong for the 10 of 10 differences checked.
  • What they have and phpgraph has not: other languages, full-text and vector search, Cypher queries (codebase-memory-mcp), an interactive visualisation and documents in the graph (graphify).

FAQ

Does phpgraph send my code anywhere? No. It runs locally, makes no network call and uses no LLM.

Does it execute my code? No. It parses PHP files and reads JSON, YAML and XML configuration. Even autoload_classmap.php and PHP service configurators are parsed, not included.

Which PHP versions can it analyse? Anything nikic/php-parser 5 parses: PHP 7 and 8 syntax. phpgraph itself needs PHP 8.2 or newer.

Which agents does it work with? Any MCP client over stdio: Sherpa, Claude Code, Codex, Cursor, Claude Desktop, and others. mcp-config prints the configuration for the common ones.

How large a project can it handle? The largest project of the corpus, Akeneo, has 8,400 files: about 10 s and 500 MB for a full build, about 3 s for a rebuild after an edit.

Limitations

  • Only PHP is read: overview names the other languages of a mixed repository (their calls to the PHP part are not linked), and the MCP instructions say at once when a project has no PHP at all.
  • Calls to global functions are not edges of the graph, though what a function returns types the calls on it (now()->addDay(), response()->json()); dynamic calls ($this->$name(), __call) are resolved only through the methods a class documents (@method, @mixin).
  • Generics are read from docblocks (@template, @extends Repository<Order>, @return Collection<int, Item>, ScalarNodeDefinition<$this>), in the project and in vendor/: a template is bound by the type of the receiver and the parents passing it on, and a foreach walks the elements of any typed collection. A union (Response|ResponseFactory, documented or declared) is called on the member declaring the method, and only when one member does; a closure passed to a method gets the parameters the method documents it passes (@param callable($this): mixed, Laravel's when(fn ($query) => ...)); @use HasFactory<HotelFactory> on a trait use binds its templates. A template of a method is bound by the arguments of each call: the class an argument names (getRepository(Order::class) with @param class-string<T>), the type of a value (@param T $value), what a closure returns (@param callable(): T, its return statements or native type). @phpstan-return and @psalm-return are read before @return; a condition the arguments do not decide (TAsync is false ? Response : PromiseInterface) gives the union of its branches. Magic properties documented with @property and @property-read carry chains on; on an Eloquent model, so do the casts ('paid_at' => 'datetime' is a Carbon, an enum, the TGet of a cast class), the timestamps and the relations read as properties ($page->book, what book()->getResults() returns). A method declaring no return type returns what the method it overrides declares (@inheritDoc). Conditions on a type ($id is array ? Collection : Model) are not evaluated, and a facade method documented as mixed (DB::transaction()) stays unknown.
  • Chains stop at magic methods and at dependencies without a usable return type. PHP's own classes (DateTime, ArrayObject, SplObjectStorage, PDO...) are known from signatures generated once (tools/internal-classes.php); those of the intl and mysqli extensions are not.
  • Without an installed vendor/, call chains stop at the first dependency.
  • Services built at runtime (compiler passes, bundle extensions, ids computed from values only known at runtime), XML routes, API Platform resources declared in XML or YAML, and API schemas other than OpenAPI and protobuf (GraphQL, AsyncAPI) are not read; an OpenAPI path rewritten by a gateway is not followed; injections that cannot be linked are listed by overview.
  • Two services declaring a message class of the same name are assumed to share it.

Contributing

Issues and pull requests are welcome. See CONTRIBUTING.md: composer check must pass (php-cs-fixer, PHPStan level max, PHPUnit), and changes to the analysis are judged on the corpus. Without PHP installed, bin/dev composer check runs everything in Docker.

What stays stable across versions (commands, MCP tools, the graph.json format) is listed in docs/STABILITY.md; changes are in the changelog.

Releases are published by pushing a version tag: the release workflow builds the PHAR, the executables for Linux, macOS and Windows (tools/build-binary.sh), and the Docker image ghcr.io/hbenabdallah/phpgraph.

License

phpgraph is source-available under the PolyForm Shield License 1.0.0: you may use, modify and redistribute it, including in a company, for any purpose that does not compete with phpgraph. Providing a competing product from this code is not allowed. This is not an OSI-approved open-source license.