From Guessing to Knowing: Building a Reproducible Bug Report in PHP
Turn “can’t reproduce” into “merged” with simple, repeatable PHP examples.
From Guessing to Knowing: Building a Reproducible Bug Report in PHP
If you’ve ever opened a GitHub issue that started with “I’m seeing a weird error…” and ended with “Can’t reproduce,” you know the pain. Reproducing a bug is the difference between guessing and knowing. It’s how you turn friction into forward motion — both for you and for the maintainer on the other end.
This guide shows you how to write PHP bug reports that are actionable, reproducible, and respectful of people’s time. We’ll walk through a real example, a minimal reproduction, a failing test, and a simple Docker setup you can drop into any issue. The goal: fewer back-and-forth comments, faster fixes, and more trust.
Why “reproducible” is the magic word
A bug report is reproducible when someone else can run your steps on their machine and see the same failure. That means:
- The environment is clear (PHP version, extensions, OS).
- The inputs are fixed (same data, same time zone/locale, same config).
- The steps are deterministic (one command, or a short list, no hand-wavy magic).
- The failure is visible (an exception, failing test, or exact output mismatch).
When all of those are true, you’ve basically handed the maintainer a failing test — maintainers love failing tests.
A quick, real example: bytes vs characters
Suppose a user’s name contains an emoji and a library function is trimming too aggressively. You see corrupted output like broken emoji or extra question marks. Your instinct says string bug. But is it the library… or your environment?
Here’s a tiny PHP script that reveals the difference between byte length and character length:
<?php
// save as repro.php
mb_internal_encoding('UTF-8');
$input = "A🙂B"; // A, smiley, B
echo "Input: $input\n";
echo "strlen: " . strlen($input) . "\n"; // bytes
if (function_exists('mb_strlen')) {
echo "mb_strlen: " . mb_strlen($input) . "\n"; // characters
} else {
echo "mb_strlen: (mbstring not installed)\n";
}
Run it:
php repro.php
On a system without mbstring, strlen("A🙂B") might report 6 (because the emoji uses multiple bytes), while mb_strlen("A🙂B") would report 3 on a system with mbstring. If your library assumed multibyte behavior but your environment didn’t provide it, you’ll see weirdness.
This is exactly the kind of minimal example that turns “It’s broken for me” into “I can see the difference — let’s fix it.”
The anatomy of a great PHP bug report
Use this checklist as your template. Copy/paste it into your next issue:
- Title: Short and specific
“
mbstring-dependent trim in Foo\Bar breaks with emoji when mbstring is missing” - Environment (copy/paste exact output)
php -v php -m composer show | head -n 20
3. Include OS, web server (if relevant), and anything unusual (Docker? Alpine? Windows?).
4. Exact versions
- PHP: 8.2.12
- Library:
vendor/package2.3.1 - Framework: Laravel 11.7 (or Symfony 7.1), if applicable
5. Minimal reproducible code (single file, no framework unless necessary)
- Short enough to read in 30 seconds
- Self-contained (no external DB unless the bug is DB-specific)
6. Expected vs actual
- Expected:
"A🙂B"stays intact - Actual:
"A?B"or length mismatch causes downstream trim
7. Steps to run
php repro.php
8. Or one composer test command.
9. Logs/stack traces (trimmed to the essential part) Wrap in code fences, remove secrets.
10. Bonus: a failing test Maintainers can run it in CI instantly.
11. Bonus: a Dockerfile or docker run snippet
Removes “works on my machine.”
Turn the report into a tiny repo (or gist) people can run
Create a throwaway directory:
repro-emoji/
├── composer.json
├── composer.lock (optional but helpful)
├── phpunit.xml
├── tests/EmojiTest.php
└── src/Repro.php
**composer.json** (only what you need):
{
"name": "yourname/repro-emoji",
"require": {
"php": "^8.1"
},
"require-dev": {
"phpunit/phpunit": "^10.5"
},
"autoload": {
"psr-4": { "Repro\\": "src/" }
}
}
**src/Repro.php**:
<?php
namespace Repro;
final class Repro
{
public static function lengthBytes(string $s): int
{
return strlen($s);
}
public static function lengthChars(string $s): int
{
if (!function_exists('mb_strlen')) {
throw new \RuntimeException('mbstring not installed');
}
return mb_strlen($s);
}
}
**tests/EmojiTest.php**:
<?php
use PHPUnit\Framework\TestCase;
use Repro\Repro;
final class EmojiTest extends TestCase
{
public function testEmojiLengthDiffersWithoutMbstring(): void
{
$input = "A🙂B";
$bytes = Repro::lengthBytes($input);
$mbInstalled = function_exists('mb_strlen');
// This test documents the bug: code that assumes character length may
// be using byte length, breaking emoji or multibyte input.
if ($mbInstalled) {
$this->assertSame(3, Repro::lengthChars($input));
$this->assertGreaterThan(3, $bytes); // typically 5 or 6
} else {
$this->expectException(RuntimeException::class);
Repro::lengthChars($input);
}
}
}
**phpunit.xml**:
<?xml version="1.0" encoding="UTF-8"?>
<phpunit bootstrap="vendor/autoload.php" colors="true">
<testsuites>
<testsuite name="repro">
<directory>tests</directory>
</testsuite>
</testsuites>
</phpunit>
Run it:
composer install
vendor/bin/phpunit
This gives the maintainer a binary yes/no on the bug’s existence and a place to start writing a fix.
Make it bulletproof with Docker
Even tiny environment differences can hide the bug. Docker nails the environment:
**Dockerfile** (toggle the mbstring line to show the difference):
# Variant A: no mbstring (shows the bug)
FROM php:8.2-cli-alpine
# Variant B: with mbstring (shows expected behavior)
# FROM php:8.2-cli-alpine
# RUN docker-php-ext-install mbstring
WORKDIR /app
COPY . /app
RUN curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer
RUN composer install --no-interaction --no-progress
CMD ["vendor/bin/phpunit", "--colors=always"]
Build & run:
docker build -t repro-emoji .
docker run --rm repro-emoji
If the bug only appears in Variant A and not Variant B, your report practically includes the fix direction: “The code path assumes mbstring; either add a polyfill or guard the call.”
Common “invisible” variables to always include
Bugs love hiding in settings that don’t show up in code:
- PHP version & SAPI:
php -v,php -i | grep -E 'Server API|Loaded Configuration' - Extensions:
php -m(especiallymbstring,intl,pdo_mysql,openssl,gd,imagick,opcache,xdebug) - Time zone & locale:
date_default_timezone_get(),setlocale(LC_ALL, 0)Date parsing bugs often vanish when both sides use the same time zone. - OPcache/JIT: enable/disable can surface edge cases in reflection or autoloading.
- INI values:
memory_limit,error_reporting,display_errors,mbstring.internal_encoding - HTTP boundary conditions: proxies,
$_SERVERheaders, unusualContent-Typecharsets - Randomness/clock: seeds, monotonic time,
microtime()precision, 32-bit vs 64-bit
When in doubt, paste php -i to a gist and link it.
Minimal means minimal
If your reproduction pulls in your whole app, it’s not minimal. Try this triage:
- Remove frameworks: can the bug be shown in a single
repro.php? - Fix the input: paste the exact payload that breaks, not a made-up one.
- Freeze time: if dates matter, force
date_default_timezone_set('UTC'). - Fail fast: throw exceptions rather than logging and continuing.
- One command: aim for a single
php repro.phporvendor/bin/phpunit.
If you can’t make it smaller, at least make it automated and documented.
A copy-paste bug report template
Use this as your starting point:
**Summary**
Brief, specific description of the failure.
**Environment**
- PHP: 8.2.12 (CLI)
- OS: macOS 14.5 (arm64)
- Extensions: output of `php -m`
- Framework/Server (if relevant): Nginx 1.25, Laravel 11.7
- Composer: 2.7.7
**Reproduction steps**
1. Clone: `git clone https://github.com/yourname/repro-emoji && cd repro-emoji`
2. Install: `composer install`
3. Run: `vendor/bin/phpunit`
**Minimal code**
(Inline the critical snippet or link to `repro.php`)
**Expected result**
Describe the behavior you expected.
**Actual result**
Paste the exact output or stack trace (trimmed).
**Notes**
- Happens only when `mbstring` is missing.
- Works when `mbstring` installed or when using `mb_strlen`.
When screenshots help (and when they hurt)
- Help: showing a UI rendering bug, an unreadable glyph, or a misaligned layout.
- Hurt: stack traces and logs. Those should always be text — searchable and copyable.
If you must show a screenshot, also paste the text version underneath.
Debugging tips that make your report stronger
- Enable full errors in dev:
error_reporting(E_ALL); ini_set('display_errors', '1');
- Log the input you’re passing into the failing function (sanitized).
- Prove the mismatch: print both
strlenandmb_strlen; printbin2hex($string)to see bytes. - Pin versions in
composer.jsonand commitcomposer.lock. - Bisect: if the bug appeared after an update, try
composer why-not vendor/package X.Y.Z.
What maintainers quietly wish every issue had
- A clear title and a single problem per issue.
- Exact versions.
- A minimal repro or failing test they can run in 10–30 seconds.
- Evidence that you tried the latest patch version.
- Polite tone. Bugs are stressful for everyone.
Conclusion
Reproducible bug reports aren’t busywork — they’re the shortest path to a fix. With a tiny script, a failing test, and a few lines about your environment, you replace guesswork with facts. The next time you hit a “weird” bug in PHP, resist the urge to paste your whole app. Instead, hand the world a one-file repro, a Docker command, and a failing test. You’ll be surprised how quickly “Can’t reproduce” turns into “Merged.”
메타데이터
- post_id
- 59aaa07fe9ae
- slug
- from-guessing-to-knowing-building-a-reproducible-bug-report-in-php-59aaa07fe9ae
- url
- https://medium.com/@annxsa/from-guessing-to-knowing-building-a-reproducible-bug-report-in-php-59aaa07fe9ae
- canonical_url
- https://medium.com/@annxsa/from-guessing-to-knowing-building-a-reproducible-bug-report-in-php-59aaa07fe9ae
- author_url
- https://medium.com/@annxsa
- status
- ok
- fetched_at
- 2026-06-09 15:37:30