1
\$\begingroup\$

I have always disliked the way PHP printed stacktraces, coming from a Java world. So I decided to implement my own ExceptionFormatter, a class that will format Exceptions/Throwables to my liking.

class ExceptionFormatter
{

    /** @var \Exception|\Throwable */
    private $exception;

    /** @var string */
    private $formattedString;

    /**
     * @param \Exception|\Throwable $exception
     */
    private function __construct($exception)
    {
        $this->exception = $exception;
        $this->formattedString = $this->formatException();
    }

    private function formatException()
    {
        return $this->formatExceptionMessage()
                .$this->formatExceptionTrace()
                .$this->getCauseIfApplicable();
    }

    private function formatExceptionMessage()
    {
        $exceptionClass = get_class($this->exception);
        $exceptionMessage = $this->exception->getMessage();

        $fileAndLine = $this->formatFileAndLine($this->exception->getFile(), $this->exception->getLine());

        if ($exceptionMessage === '')
            return "${exceptionClass} (${fileAndLine})\n";

        return "${exceptionClass}: ${exceptionMessage} (${fileAndLine})\n";
    }

    private function formatFileAndLine($file, $line)
    {
        return "${file}:${line}";
    }

    private function formatExceptionTrace()
    {
        $exceptionTrace = $this->exception->getTrace();

        $formattedTrace = [];

        foreach($exceptionTrace as $trace) {
            $formattedTrace[] = "\tat ".$this->formatTraceElement($trace);
        }

        return implode("\n", $formattedTrace);
    }

    private function formatTraceElement($traceElement)
    {
        $fileAndLine = $this->formatFileAndLine(
            isset($traceElement['file']) ? $traceElement['file'] : 'unknown',
            isset($traceElement['line']) ? $traceElement['line'] : 'unknown'
        );

        if ($this->isFunctionCall($traceElement)) {
            $functionCall = $this->formatFunctionCall($traceElement);
            $arguments = $this->formatArguments($traceElement);

            return "${functionCall}(${arguments}) (${fileAndLine})";
        }

        return $fileAndLine;
    }

    private function isFunctionCall($traceElement)
    {
        return array_key_exists('function', $traceElement);
    }

    private function formatFunctionCall($traceElement)
    {
        return (isset($traceElement['class']) ? $traceElement['class'] : '')
                .(isset($traceElement['type']) ? $traceElement['type'] : '')
                .$traceElement['function'];
    }

    private function formatArguments($traceElement)
    {
        /** @var string[] $arguments */
        $arguments = $traceElement['args'];

        $formattedArgs = [];
        foreach ($arguments as $arg) {
            $formattedArgs[] = $this->formatArgument($arg);
        }

        return implode(', ', $formattedArgs);
    }

    private function formatArgument($arg)
    {
        if (is_string($arg)) {
            return "\"".$arg."\"";
        } else if (is_array($arg)) {
            return 'Array';
        } else if ($arg === null) {
            return 'null';
        } else if (is_bool($arg)) {
            return $arg ? 'true' : 'false';
        } else if (is_object($arg)) {
            return get_class($arg);
        } else if (is_resource($arg)) {
            return get_resource_type($arg);
        } else {
            return $arg;
        }
    }

    private function getCauseIfApplicable()
    {
        $previousException = $this->exception->getPrevious();
        if ($previousException !== null)
            return "\nCaused by: " . self::format($previousException);

        return '';
    }

    /**
     * Converts an Exception to a Java-style stack trace string.
     * 
     * @param \Exception|\Throwable The Exception/Throwable to format as a "pretty" string. 
     * @return string
     */
    public static function format($exception)
    {
        $formatter = new ExceptionFormatter($exception);
        return $formatter->getFormattedString();
    }

    public function getFormattedString()
    {
        return $this->formattedString;
    }
}

https://3v4l.org/WNLbO

The output looks like this:

LogicException: Lulz! (/in/WNLbO:158)
  at nestedFunction() (/in/WNLbO:164)
  at throwExceptionForLulz(Array, "String", stdClass, false, "a", "b") (/in/WNLbO:168)
Caused by: RuntimeException: This is the cause (/in/WNLbO:150)
  at throwCause() (/in/WNLbO:156)
  at nestedFunction() (/in/WNLbO:164)
  at throwExceptionForLulz(Array, "String", stdClass, false, "a", "b") (/in/WNLbO:168)

Usage:

try {
    methodThatThrowsException();
} catch (Exception $e) {
    echo ExceptionFormatter::format($e);
}

I have tried to keep the whole thing as simple and self-explanatory as possible (which happens to be my excuse for the lack of comments).

\$\endgroup\$
4
  • \$\begingroup\$ Should this be tagged reinventing-the-wheel? \$\endgroup\$ Commented Oct 25, 2016 at 19:45
  • \$\begingroup\$ If you say that your solution is better than the standard functionality, then I would not consider it reinventing the wheel. \$\endgroup\$ Commented Oct 25, 2016 at 19:47
  • \$\begingroup\$ @200_success Not saying it is better, it was mostly an exercise \$\endgroup\$ Commented Oct 25, 2016 at 19:48
  • \$\begingroup\$ I just want to mention filp.github.io/whoops which is a great package when working with errors and exceptions. Your class can be a handler for this package, along other things. \$\endgroup\$ Commented Oct 26, 2016 at 12:42

1 Answer 1

1
\$\begingroup\$

I don't understand why you would ever need a concrete instance of this class, as I would guess usage would be something like:

try {
   // something that could throw Exception
} catch (Exception $e) {
   // log in preferred format
   error_log(ExceptionFormatter::getFormattedString($e));
}

There is no reason to apply this format until you actually get to the point of logging, thus no reason to have a concrete object to pass around.

Even in your static method, you instantiate an object of the class, which I just don't see any value in doing. I would consider having a single public static method like getFormattedString() (though I think method name could be simplified as it seems redundant to class name), and have everything else be private static functions to break up different elements of the work like you are currently doing.

Also, if you are thinking about being compliant with PHP7, you might need to make this class operable against anything that implements Throwable interface, as in PHP7, Errors typically become Throwable and thus might need to be handled as well.

In your format() method, which is currently your public interface, you should type hint that the $exception parameter should be Exception or Throwable. Right now you don't do anything to enforce that a proper dependency is passed to this method.

\$\endgroup\$
2
  • \$\begingroup\$ Thank you! The main reason I made it a full-blown class is so that I am able to share the $exception variable across functions, so I don't need to pass it as a parameter. The constructor is private anyway :) \$\endgroup\$ Commented Oct 25, 2016 at 20:19
  • \$\begingroup\$ @Pete Added additional comment on type-hinting that you may want to consider. To your comment, I agree it might seem easier to access concrete reference to the exception, but my concern is more in keeping around a duplicate copy of the output string in the concrete object. A long stack trace might present a fairly large string from a memory consumption standpoint. I worry about consuming too much memory overhead by having both an under-construction string and then a "copy" of that string stored on the object. I am always wary of introducing performance problems on a logging mechanism. \$\endgroup\$ Commented Oct 25, 2016 at 20:33

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.