1. Reproduce it in the smallest possible case

A bug you cannot reproduce is a bug you cannot fix. Narrow it: which request, which user role, which data. Then reduce it — a failing test, a single script, a minimal case. If the bug is data-dependent, extract the offending row into a fixture.

For environment-dependent bugs (works locally, fails in production), the difference is your first suspect: PHP version, extension versions, server config, file permissions, opcache.

2. Isolate with logs and structured errors

Replace var_dump with structured logging that records the actual request context: method, URI, headers, session ID, and the backtrace. In production, log to a file or service — never the browser output. Enable error display nowhere near production; log everything, display nothing.

A 500 error with a generic message is a logging configuration problem, not a mystery. Turn on error_log, catch exceptions in a handler, and log the exception object including the stack trace.

// Never echo errors in production; log them with context
error_reporting(E_ALL);
ini_set("log_errors", "1");
ini_set("display_errors", "0");

set_exception_handler(function (Throwable $e) {
    error_log(sprintf(
        "%s in %s:%d -- %s %s",
        $e->getMessage(),
        $e->getFile(),
        $e->getLine(),
        $_SERVER["REQUEST_METHOD"] ?? "CLI",
        $_SERVER["REQUEST_URI"] ?? ""
    ));
    http_response_code(500);
    echo "Internal server error.";
});

3. Instrument, fix, and prove it with a test

Once you have the failing path reproduced and instrumented, the fix is usually small and boring. The important part is proving it: write a regression test that fails on the old behavior and passes on the new one. If the code has no test harness, that is part of the problem — add one.

Then remove the temporary instrumentation, run the full test suite, and check the logs for a clean pass. A bug fixed without a regression test is a bug that will be reintroduced.