← Back to list

From Guessing to Knowing: Building a Reproducible Bug Report in PHP

Turn “can’t reproduce” into “merged” with simple, repeatable PHP examples.

Ann R. · 2025-10-04 19:12 · 27 claps · 5.6 min read paywalled
#php #bug-report #phpunit #debugging #error-reporting
Open on Medium ↗
Wiki topics: 💻 · Programming

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:

  1. Title: Short and specific mbstring-dependent trim in Foo\Bar breaks with emoji when mbstring is missing”
  2. 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/package 2.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 (especially mbstring, 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, $_SERVER headers, unusual Content-Type charsets
  • 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:

  1. Remove frameworks: can the bug be shown in a single repro.php?
  2. Fix the input: paste the exact payload that breaks, not a made-up one.
  3. Freeze time: if dates matter, force date_default_timezone_set('UTC').
  4. Fail fast: throw exceptions rather than logging and continuing.
  5. One command: aim for a single php repro.php or vendor/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 strlen and mb_strlen; print bin2hex($string) to see bytes.
  • Pin versions in composer.json and commit composer.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