
phpgraph
io.github.hbenabdallahPublished by GitHub account hbenabdallahv2.0.0Updated Oct 31, 2026
Knowledge graph of a PHP codebase by static analysis: calls, routes, buses, layers, impact
Overview
Builds a static-analysis knowledge graph of a PHP codebase and serves it over MCP so an assistant can trace calls, routes, messages, layers and change impact.
- What you can do
- Get a project overview: Composer stack, namespace tree with layers, bounded contexts, layer-rule violations, messages, HTTP routes, services and known gaps
- Find code by topic or route when class names are unknown, and read one class, method, route or channel with all its relations
- Outline a feature or method: classes by layer, signatures, routes running into it, behaviour read from bodies, wiring, tests
- Trace dependencies: what uses a node or what it depends on, shortest path between two pieces of code, and hub classes most code depends on
- Assess change impact: every dependent class nearest first, callers through interfaces, routes reaching it, and the tests to run
- When to use it
- Useful when an assistant works in an unfamiliar or large PHP project (Symfony, Laravel, Ecotone, PrestaShop, WordPress, DDD or hexagonal) and needs to answer who calls what, which handler receives a message, which controller serves a route, or what breaks if a method changes, instead of grepping file by file.
- Requirements
- Runs locally over stdio as a single executable (Linux, macOS, Windows) with no PHP needed, or via Composer with PHP 8.5+, a PHAR, or the Docker image. The project path is passed to the serve command. No account, API key or network access is required; the graph is built on first use and rebuilt incrementally. Optional environment variables set memory limit, worker count and install directory.
Installation
In SourceWeft
- Open phpgraph in the dashboard and add it to a workspace.
- Enable the server for the chats that should use its tools.
Desktop only via STDIO. STDIO servers start a local process, so they need the SourceWeft desktop host.
Other MCP clients
Follow the launch instructions in the repository.
README
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.5+] [License: MIT] [Listed on mcpservers.org]
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, docblocks with phpstan/phpdoc-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) orAMBIGUOUS(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 yourvendor/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; }, alsocanHandle,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 $querygiven tosetUp(CreateEstimateQueryInterface $query): aCreateEstimateQuery); a guard it cannot read, or a rule running for some of these classes only, is kept, markedsupports() 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.
impactlists the operations behind a change (the contract its clients rely on),outlinethose 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):
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.5+ installed, or with Docker:
2. Connect your agent. mcp-config prints a ready-to-paste configuration with the paths filled in (vendor/bin/phpgraph when installed with Composer):
Agents reach for grep first, even with phpgraph connected. mcp-config also prints a line for the instructions file of the project (CLAUDE.md, AGENTS.md) telling the agent to call phpgraph before grep or reading files; the MCP instructions of the server say it too.
With Sherpa, a terminal coding agent, one declaration serves every project: Sherpa starts its MCP servers from the project it runs in.
In a Sherpa session, /mcp add does the same, and the tools are there at once. Both write ~/.config/sherpa/mcp.json.
With Claude Code (absolute paths, as mcp-config prints them):
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
The server tells the agent to call overview first. A path, as an agent sees it, on php-ddd-example:
Command line
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. The files are parsed by one worker process per processor, with PHP's pcntl extension (in the binaries and the Docker image; not on Windows, where they are parsed one after the other): Sylius builds in 10 s instead of 28 s on a 12-thread laptop. PHPGRAPH_WORKERS=4 sets the number of workers, PHPGRAPH_WORKERS=1 parses in one process. 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
Every relation carries a confidence level:
Frameworks and conventions recognised
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:
The project's own rules go in phpgraph.yaml, checked by the same command, with the same baseline. Patterns are globs on class names (* within a namespace segment, ** across segments), and a namespace ending with \ is everything in it:
Dependencies are the certain ones (EXTRACTED and INFERRED: types, attributes, new, calls, inheritance) of application code; inheritance is read from the graph, so the parents of a class of vendor/ are not known. The bounded context of a handler and of a message is read from their namespaces, as for the layers.
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): achannel: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_bythe 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:
Routing files of your own: action codes in XML
Legacy applications often route by an action code the screen sends (value="saveReservation"), mapped to a controller method by an XML file of their own. phpgraph.yaml describes the format, nothing is hard-coded:
Each action is a route node, ACTION saveReservation, handled_by its method (EXTRACTED), and referencing its controller (via controller) when the method is inherited. Its target and forwards are references to the next action or to the template (_Stock/SO_080/Resa.html finds _Stock/views/SO_080/Resa.html), via target or forward KO. A file of cited_in quoting a code (value="saveReservation", 'saveReservation&id=') references the action, AMBIGUOUS: impact_of lists those screens under the route. A code that is a plain word (default, login) is not looked for, nor are minified files. overview counts the actions and lists those whose controller or method is missing; outline of a method starts its flow from its action; query_graph("saveReservation routes") finds it. controller_class: '{name}CTRL' finds the class by name instead of by file.
How it works
- Every PHP file is parsed into facts: declarations, calls with the expression typing their receiver, message sends and handlers, routes, HTTP calls.
- 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. - The graph is saved as
phpgraph-out/graph.json, withGRAPH_REPORT.mdfor 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. Builds measured by corpus:measure, one project at a time, in Docker (PHP 8.5, no opcache), version 1.5.8.
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,500 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:
The ranges of the runs with and without phpgraph do not overlap.
Compared with other code graphs
Kimai 2.70.2 (Symfony, 1,072 source and 761 test files, not in the corpus phpgraph is measured on), October 2026, without any LLM: phpgraph 1.5.2 and graphify 0.9.93 (extract --code-only), on the same machine, in Docker. graphify keeps one edge per caller and callee, so calls between methods of the project are compared by pair; samples of the differences were checked in the code. tools/graphify/benchmark.sh runs it again.
- The pairs found by phpgraph only are resolved from declared types, inherited methods and
instanceofincluded: 14 of 14 checked right. graphify misses calls on local variables and chains even when the method name is unique ($sut->hasUser(),$this->getDateTimeFactory()->getStartOfMonth()). - Of graphify's 356 pairs, 243 link a call phpgraph links to another method of the same name: phpgraph is right on 11 of 12 checked, graphify on the twelfth (phpgraph follows the declared return type,
AbstractWidgetType, to the inherited method; the object is a subclass overriding it). The 113 others: 11 of 12 checked wrong, calls on another object linked to the method of the same name in the current class (Doctrine's$em->rollback()toTimesheetRepository::rollback()); the twelfth is right, a call on a helper without a return type, which phpgraph counts as an unknown receiver. - phpgraph's one miss among the 2,408 files:
$user->isFirstDayOfWeekSunday()on whatloadUserByIdentifier()declares to return, Symfony'sUserInterface, which has no such method. - What graphify has and phpgraph has not: other languages (Kimai's JavaScript), communities, an interactive visualisation and documents in the graph.
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.
- 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()tofoo()of the current class: right for$this->foo(), wrong for the 10 of 10 differences checked. - What codebase-memory-mcp has and phpgraph has not: other languages, full-text and vector search, Cypher queries.
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, and the PHP 4 and 5 syntax removed since ($s{0}, =& new, octals like 08), for a file the newest grammar rejects. phpgraph itself needs PHP 8.5 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 22 s and 730 MB for a full build (in Docker, without opcache), about 3 s for a rebuild after an edit.
Limitations
- Only PHP is read:
overviewnames 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). - Docblocks are read by phpstan/phpdoc-parser: a type may be written over several lines, a tag may follow another on the same line of a one-line docblock, and the forms older code writes though PHPDoc does not allow them are read as they mean (
callable(Foo $x)without a return type,?A|B,@method static NAME(): Type). Generics are read from docblocks (@template,@extends Repository<Order>,@return Collection<int, Item>,ScalarNodeDefinition<$this>), in the project and invendor/: a template is bound by the type of the receiver and the parents passing it on, and aforeachwalks the elements of any typed collection. A union (Response|ResponseFactory) or an intersection (Countable&Named), documented or declared, is called on every member declaring the method; a closure passed to a method gets the parameters the method documents it passes (@param callable($this): mixed, Laravel'swhen(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-returnand@psalm-returnare 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@propertyand@property-readcarry chains on; on an Eloquent model, so do the casts ('paid_at' => 'datetime'is a Carbon, an enum, theTGetof a cast class), the timestamps and the relations read as properties ($page->book, whatbook()->getResults()returns). A method declaring no return type returns what the method it overrides declares (@inheritDoc). Aglobal $wpdbhas the class the function's@global wpdb $wpdbgives; without the tag, it stays unknown. Conditions on a type ($id is array ? Collection : Model) are not evaluated, and a facade method documented asmixed(DB::transaction()) stays unknown. - Local variables follow the branches of a function: where
if/elseif/else,switch,match,try/catch, a ternary,??or a loop meet, a variable holds what any branch reaching that point assigned (one ending inreturn,throw,exit,breakorcontinuedoes not reach it,nulland scalars add nothing).instanceof,is_a(),assert(),&&/||andmatch (true)narrow a variable where they hold, a negated test followed by an exit (if (!$x instanceof Foo) { return; }) narrows the code after it. A call on such a variable is linked to the method of each class it may hold: INFERRED when every branch typed it, AMBIGUOUS when one left it of unknown type. A call narrows its arguments as its callable documents it, in the project or invendor/:@phpstan-assert,@psalm-assertand their-if-true/-if-falseforms, a template bound by aclass-stringargument included (WordPress'sis_wp_error(),Webmozart\Assert\Assert::isInstanceOf($x, Order::class), PHPUnit'sassertInstanceOf()); only calls named like an assertion are looked up (is…,assert…,ensure…,check…,expect…,validate…,verify…, any static method of an…Assertor…Assertionclass). Only variables are narrowed, not properties ($this->x instanceof Foo), and a loop body is read once (a type assigned at its end is not seen at its start). - What a library makes of a call its signatures do not say comes from its provider (
src/Builder/Libraries): a PSR-11 container asked for a class gives an instance of it ($container->get(Mailer::class)), an Eloquent model's attributes have the types of their casts, timestamps and relations. - 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 theintlandmysqliextensions 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 (but action files mapped in
phpgraph.yaml), 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 byoverview. - 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 open source under the MIT License: you may use, copy, modify, merge, publish, distribute, sublicense and sell it, for any purpose, as long as the copyright notice and the license text are kept with it.
Source: README.md at commit 77490b5
Tools
0Version history
14- v2.0.0LatestOct 31, 2026
- v1.6.1Oct 30, 2026
- v1.5.8Oct 29, 2026
- v1.5.7Oct 23, 2026
- v1.5.6Oct 22, 2026
- v1.5.5Oct 22, 2026
- v1.5.4Oct 22, 2026
- v1.5.2Oct 21, 2026
- v1.4.3Oct 17, 2026
- v1.4.2Oct 14, 2026
- v1.4.1Oct 14, 2026
- v1.4.0Oct 14, 2026
- v1.3.4Oct 13, 2026
- v1.3.1Oct 12, 2026
Score
50% of the applicable checks met
How complete this listing is, computed from the registry entry, the endpoint and public sources. Not a rating of the server and not a statement that it is safe or working today: reachability is a dated check, and no installs or reviews go into the score.
- Reached in the last 30 days25 pts
Keep the endpoint answering the MCP handshake; SourceWeft probes it every 30 days.
- Auth declared10 pts
Declare the credential headers in the registry entry, or keep the endpoint answering the probe so its auth can be classified.
- Tools listed15 pts
Answer tools/list without a credential, or ship a package that starts in a sandbox without one.
- DescriptionRequired15 pts
- Logo10 pts
- Documentation15 pts
- License10 pts