Throwing an exception in a __toString call will cause a fatal error, making it impossible to catch, or even see, the exception. For us, and many other developers, this can be very frustrating. Instead of adding a try/catch block in every __toString method we write, we've chosen not to use __toString altogether.
Why will PHP trigger a fatal error?
The reason why PHP will trigger a fatal error is explained in a mailing list thread from april 2007. Johannes Schlüter writes:
casts to string happen in quite different places in the engine. For different reasons we can't assure that an exception throw in these situations would be handled correctly by the engine. This won't change until great parts of the engine are rewritten.
If you read the last mailing list thread about a possible solution, you'll get a better understanding of the complexity of such a solution and why we still don't have one, over 12 years after the first proposal to add __toString as a concept in PHP.
So, just add a try/catch block
We could have done that, just add a try/catch block in every of our 242 __toString methods and be done with it. The problems with that approach are: It will prevent thrown exceptions to be caught and handled at the right point in code. It is not future-proof; it doesn't prevent anyone from writing a new __toString method without a try/catch block and it doesn't solve another major concern we have with the __toString method.
If you, like us, are working on a very large project, you'll start to rely on your IDE. It can tell you where functions are used, where the function being used was created and much more. But not for __toString. There is no reliable way for your IDE to know where an object is converted to a string and therefor there is no way for you to know what parts of the code you'll be affecting when you change any of the __toString methods. The same goes for other magic methods (like __set, __get and __call), although using annotations can work around this problem sometimes.
From __toString to toString
Not using __toString was the obvious solution, but what about objects that simply have a string representation? We still wanted to be able to use those objects as if they were a string, but they should have a normal (as in: not magic) method for string conversion that any IDE can understand from which exceptions may be thrown.
To solve this we introduced an interface called StringRepresentation defining a single method: toString.
interface StringRepresentation
{
/**
* @return string
*/
public function toString();
}
Any code may now detect if a variable is an instance of this interface and explicitly cast such a variable to a real string. Now we can really see where our objects are cast to a string and we really know what code we will be affecting when we change such a toString method.
Of course this still does not prevent anyone from adding a new __toString method, reintroducing the problems described above. To avoid this, we've added a rule to our CodeSniffer rule set that will indicate any __toString method as deprecated in our IDE's. That, and we keep our fingers crossed ;).
Introducing a new way to cast objects to strings is no rocket science, getting rid of the current 242 implementations of the deprecated method is, for the same reason we stopped using it: There is no way to know where a __toString method is called. This is why we introduced another class to help us out:
abstract class TransitionalStringRepresentation implements StringRepresentation
{
/**
* @return string
* @deprecated
*/
final public function __toString()
{
if (Core::isTypeDevelopment()) {
trigger_error('__toString() called on instance of ' . get_class($this), E_USER_WARNING);
}
try {
return $this->toString();
} catch (Exception $Exception) {
trigger_error('Uncaught exception: ' . $Exception->getMessage() . ' in ' . $Exception->getFile() . ' on line ' . $Exception->getLine() . PHP_EOL .
$Exception->getTraceAsString(), E_USER_ERROR);
return '';
}
}
}
Any class currently implementing __toString will be changed to extend the TransitionalStringRepresentation class. This will make sure that the class itself no longer has a __toString method (since TransitionalStringRepresentation::__toString is final) and that developers will see warnings when calling such a __toString method in development. When we're confident that most of the __toString calls have been eliminated from our codebase will remove the Core::isTypeDevelopment condition from the code. We can then use our error monitoring system to tell us where in production __toString is still being used and eliminate even the last place that objects are magically converted to strings.
Yes, this is a tedious operation. Yes, this takes a lot of time and effort. But take in consideration that our codebase has been around for nearly twelve years and will be around for many years to come. It is important for us to be able to do this kind of thorough refactorings. Even if such a refactor takes over a year.