# phpspy samples PHP stacks from the outside, without touching the code

> phpspy is a C sampling profiler that reads the stack of a running PHP 7 process, so you can see where time goes on a live server without instrumenting the application. The cost is a narrow platform target and a set of flags you have to understand before the output is useful.

**adsr/phpspy** — low-overhead sampling profiler for PHP 7+

- Repository: https://github.com/adsr/phpspy
- Stars: 1,501 · Forks: 73
- Language: C
- License: MIT
- Published: 2026-10-16 · Updated: 2026-10-16 · Language: en
- Canonical page: https://hysenlabs.com/projects/adsr-phpspy

## phpspy reads another process's stack instead of instrumenting your PHP

The design choice that makes phpspy different is where it sits relative to the application. It is a separate C program that attaches to a running PHP process and samples its stack, rather than a PHP extension loaded into the interpreter. The README describes it as a low-overhead sampling profiler for PHP and stresses that it works with no changes to the application and minimal overhead. The repository layout backs that up: the sources are phpspy.c, phpspy_trace.c, pgrep.c, top.c, addr_objdump.c and the event handlers event_fout.c and event_callgrind.c, all compiled into a single binary with no PHP code of its own.

The trade-off against an extension based profiler is the resolution of what you can see. A sampling profiler sees the call stack at the instants it chooses to sample, so it tells you which functions are hot, but it does not instrument every call and therefore does not give you a complete call graph for a single request. What it does give you is the ability to profile a process that is already running in production, including one behind Apache or FPM, without a redeploy. For a server where you suspect a hot loop but cannot reproduce it locally, that is the whole point.

Sampling is controlled by two flags that mirror each other. The default is a rate of 99 traces per second, and the equivalent sleep between samples is 10101010 nanoseconds. You set one or the other, and the tool reports the other.

## The platform target is narrow, and aarch64 is still experimental

Read the support matrix before you plan to deploy this. The README states that phpspy works with non-ZTS PHP 7.0+ using the CLI, Apache and FPM SAPIs, on x86_64 Linux 3.2 or newer. Experimental support for aarch64 is included. That is three separate constraints, and each one bites in a different way.

The ZTS constraint is the sharpest. ZTS builds are thread-safe builds of the interpreter, and phpspy does not support them, so if your production PHP comes from a distribution that ships ZTS by default, the tool will not do what you expect there. The SAPI list matters too: CLI, Apache and FPM are covered, and the option list includes a php-version flag that accepts 70 through 86, with 70, 71, 72, 73, 74, 80, 81, 82, 83, 84, 85 and 86 listed as supported. The version defaults to auto detection, so in most cases you should leave it alone.

The Linux-only requirement is the third. There is no macOS or Windows build described in the documentation. The Makefile compiles with -std=c11 and pthread, and the build uses php-config only in the optional Zend mode, so a plain build needs a C compiler and a POSIX system. The Dockerfile in the repository sidesteps the platform question by starting from php:8.1-fpm-alpine, which gives you a container image of the tool rather than a native build for your laptop.

## Two build paths decide how phpspy finds the interpreter internals

Building is a matter of cloning and running make. The default build uses built-in structs, which is what lets phpspy run against a PHP binary it knows nothing about, since it ships its own copy of the relevant structure layouts. The alternative is USE_ZEND=1, which uses the Zend structs from the PHP development headers and requires php-config to be present. The Makefile enforces that: if USE_ZEND is set and php-config cannot be found, the build stops with the error Need php-config.

```bash
git clone https://github.com/adsr/phpspy.git
cd phpspy
make
```

```bash
USE_ZEND=1 make ...
```

The first block clones and builds with the built-in structs, and the second is the header based variant. In the Makefile the build also embeds the git short SHA as a COMMIT define when it is present, so a binary you build from a checkout carries its own revision. Installation is a standard make install that copies the binary into $(prefix)/bin, with prefix defaulting to /usr/local.

The container path exists if you would rather not compile on the host:

```dockerfile
FROM php:8.1-fpm-alpine

RUN apk add --update alpine-sdk \
    && git clone --recursive https://github.com/adsr/phpspy.git \
    && cd phpspy \
    && make
```

The recursive clone flag matters here because the repository carries a vendor/ directory as a submodule, and the flamegraph helper lives in it.

## Attaching to a live process, and the pgrep mode that scales it

The synopsis defines three ways to select a target: a single pid with -p, a set of processes matching pgrep with -P, or a command to launch and profile with --. The README's own example attaches to the most recently started httpd process:

```bash
sudo ./phpspy --limit=1000 --pid=$(pgrep -n httpd) >traces
```

The sudo is there because reading another process's memory requires elevated privilege, and the permission requirement is worth internalising before you try it. The --limit=1000 flag caps the number of traces collected, and the output is redirected to a file called traces for later processing.

pgrep mode is the one that changes character on a busy server. With -P, phpspy traces every process matching the pgrep arguments concurrently, and it spawns a pool of worker threads, 16 by default, controlled by -T. Each worker writes to the same output stream, which is where a documented pitfall appears. The buffer size option defaults to 4096, which is PIPE_BUF, and the help text warns that setting it above 4096 in -P mode may lead to interlaced writes across threads unless -J m is specified. -J m is the event handler option that turns on a mutex to prevent interlaced writes on stdout in -P mode. If you plan to use -P for anything you intend to parse, either keep the buffer at the default or turn the mutex on.

There is also a pause-process flag, -S, which the help marks as unsafe for production. It freezes the target while the stack is read, which removes a class of races at the cost of stalling a live request.

## Beyond stacks: request fields, memory usage and variable peeking

phpspy can carry more than a function name on each sample, and these options are what turn a stack dump into something you can act on. The request-info option -r selects which request parts to capture, using a compact letter scheme where q is query, c is cookie, u is uri and p is path, with a capital letter meaning negation. The default is QCUP, and the help notes that none means no request fields. So the default records the query string, cookies, the URI and the path for each trace, which is how you connect a hot function to the specific request that triggered it.

The -m flag adds peak and current memory usage to each trace, and the help is explicit that this requires the target PHP process to have debug symbols. Without them, the numbers are not available. That is a deployment detail, not a flag you can force: if your production PHP was built without debug symbols, you will not get memory figures from a production host, and the only fix is a different PHP build.

The -e option peeks at the contents of a variable identified by a varspec of the form <varname>@<...>. Beyond that, the top mode gives a top-like display, and the event handler can be switched to callgrind instead of fout.

One more thing to know before you collect anything: the request fields you capture can include cookies and query strings, so traces can end up holding session identifiers and tokens. Redirecting to a file, as the README example does, puts that data on disk.

## Turning traces into a flamegraph with the bundled Perl script

The output format is designed to feed a flamegraph tool rather than a terminal viewer. The README shows the full pipeline, from the collected traces file through a collapse script to a flamegraph:

```bash
./stackcollapse-phpspy.pl <traces | ./vendor/flamegraph.pl >flame.svg
```

```bash
google-chrome flame.svg
```

The first block pipes the traces through stackcollapse-phpspy.pl, which folds the repeated stacks into the collapsed format flamegraph.pl expects, and writes flame.svg. The second opens the result. The flamegraph script itself lives in vendor/, which is why the build instructions clone with --recursive.

This is the practical difference between phpspy and the usual in-process alternative. A PHP extension profiler can produce richer data, including per-function call counts and a full call graph, because it hooks the interpreter directly. phpspy trades that completeness for the ability to attach to a process that is already serving traffic, and for an output stream you can post-process. If you already control the deployment and can turn on an extension in a staging environment, that route gives you more detail. If the problem only appears in production and you cannot change the running code, phpspy is the tool that can still see it.

The SVG output is a file you can attach to a ticket, but it is also a file that contains request data, so treat it with the same care as the raw traces.

## MIT licensing, a two-year release gap and active commits on master

The licence is MIT, which is permissive and short. You can use, modify and redistribute the code, including in commercial and closed-source products, as long as the copyright notice and the licence text travel with it. The repository ships a LICENSE file at its root, so keeping that file intact in a vendored copy is the practical obligation. That is a licensing fact rather than legal advice, and it is one of the easier adoption questions in this ecosystem.

The maintenance record is mixed in a way worth reading carefully. The default branch is master, the repository is not archived, and the last push was on 2026-10-15, so commits are landing. The release tags tell a different story: v0.6.0 on 2023-09-06, v0.5.0 on 2019-12-04, and the most recent release, v0.7.0, on 2024-07-10. In other words, work continues on master while the published releases have not moved in more than two years.

Practically, that means you should build from a checkout rather than expect a packaged release to match master, and you should expect to compile it yourself on each platform you care about. The Makefile also carries a test target that runs the shell tests under tests/ with a PHPSPY and PHP override, and a shellcheck target that runs shellcheck across the scripts, so there is a way to check a build before you trust it.

## Conclusion

phpspy suits an operator who has a live PHP worker that is misbehaving and no access to the source or the deployment pipeline, because it attaches to a running pid and needs no code change. It does not suit a team that profiles during development, since it does not provide a per-request timeline, and it does not run on PHP with ZTS, on Windows, or on macOS. Before relying on it, build it with make on a kernel matching your production host, confirm the target process is a non-ZTS build, and check that the -P mode output is not interlaced by keeping the buffer size at 4096 unless you also pass -J m.

## FAQ

### How do I build and run phpspy against a live PHP process?

Clone the repository, run make, then attach with sudo ./phpspy --limit=1000 --pid=$(pgrep -n httpd). Attaching to another process requires elevated privileges because the tool reads that process's memory.

### Does phpspy work on macOS or Windows?

The documented target is x86_64 Linux 3.2 or newer, with experimental support for aarch64. The repository also ships a Dockerfile based on php:8.1-fpm-alpine if you want to run it in a container.

### How do I capture request URLs, cookies and query strings in phpspy?

If the option that captures request fields is not enabled you will get no request context at all, since the default set of request parts can be set to none. The letter codes are q for query, c for cookie, u for uri and p for path, and a capital letter negates a part.

### How do I avoid interleaved output when tracing many processes?

In -P mode each worker thread writes to the same output, so raising the buffer size above 4096 can interlace writes. Keep the buffer at its default of 4096 or pass the event handler option -J m to use a mutex that prevents interlaced writes on stdout.

### Can I use phpspy on a production server?

Attaching itself is described as low overhead and needs no application changes, but the -S pause-process option is marked unsafe for production, and capturing request fields can put cookies and query strings into the trace files. The memory usage option also requires the target process to have debug symbols.

## Sources

- [adsr/phpspy on GitHub](https://github.com/adsr/phpspy)
- [Issues](https://github.com/adsr/phpspy/issues)
- [License: MIT](https://github.com/adsr/phpspy/blob/master/LICENSE)
- [README](https://github.com/adsr/phpspy/blob/master/README.md)
- [Releases](https://github.com/adsr/phpspy/releases)

---

Hysen Labs editorial analysis, written from the project's own repository and release notes. Cite the canonical page: https://hysenlabs.com/projects/adsr-phpspy
