What this usually means
Your code threw an Exception object (or a subclass like RuntimeException, InvalidArgumentException, etc.) but never caught it with a try-catch block, and no global exception handler was set via set_exception_handler(). PHP's default behavior for uncaught exceptions is to log the error with the full stack trace and terminate execution. Unlike warnings or notices, this is a fatal error—your script does not recover. Common triggers include failed database connections, missing files (e.g., PDOException from a bad query), invalid user input, or improperly configured autoloading that throws ReflectionException or ErrorException.
The first ten minutes — establish facts before touching code.
- 1Check the PHP error log immediately: tail -100 /var/log/php_errors.log or find the log with php -i | grep error_log
- 2Identify the exception class and message from the log line: it says 'Uncaught Exception' followed by the class name (e.g., PDOException, InvalidArgumentException).
- 3Look at the stack trace in the log: the first line in the stack trace is where the exception was thrown, the last line is where it was uncaught (the top-level script).
- 4If you have a framework debug page (e.g., Laravel Whoops), read the 'Previous exceptions' section—sometimes the real issue is deeper.
- 5Enable Xdebug's stack trace display: set xdebug.show_exception_trace=1 in php.ini to see the trace even in production (but don't leave on permanently).
- 6If the exception is thrown inside a loop or callback, check if any catch block exists up the call stack—often missing in event handlers or closures.
The specific files, logs, configs, and dashboards that usually own this bug.
- searchPHP error log (usually /var/log/php_errors.log or /var/log/php/error.log; find it with php -r 'echo ini_get("error_log");')
- searchWeb server error log (e.g., /var/log/apache2/error.log or /var/log/nginx/error.log) for the HTTP 500 and PHP error reference
- searchThe exact file and line mentioned in the fatal error message (e.g., /var/www/html/index.php:42)
- searchAny try-catch blocks in the call stack—especially the outermost script that lacks a global catch
- searchFramework's exception handler configuration (e.g., app/Exceptions/Handler.php in Laravel, or bin/console exception handling in Symfony)
- searchThe code that throws the exception: look for throw new SomeException(...) or a library call that throws, like PDO::prepare()
- searchComposer's autoload setup if the exception is a ClassNotFoundException or ReflectionException
Practical causes, not theory. These are the things you will actually find.
- warningNo try-catch around a risky operation like database queries, file I/O, or API calls
- warningForgetting to wrap the entire request handler in a try-catch (e.g., in index.php or a front controller)
- warningRemoving or not registering a global exception handler with set_exception_handler()
- warningThrowing a custom exception that extends Exception but never catching it in the calling code
- warningA library or package that throws an exception under unexpected conditions (e.g., PDO throws PDOException on connection failure)
- warningAutoloading failure that throws ReflectionException or ErrorException when a class is not found
- warningMisconfigured framework exception handler that re-throws or doesn't handle the exception gracefully
Concrete fix directions. Pick the one that matches your root cause.
- buildWrap the code that throws in a try-catch block: try { riskyOperation(); } catch (Exception $e) { // handle gracefully }
- buildRegister a global exception handler with set_exception_handler() to catch all uncaught exceptions and show a user-friendly response
- buildIn a framework, customize the exception handler (e.g., Laravel's render() method in App\Exceptions\Handler to return JSON or a custom view)
- buildConvert exceptions to HTTP responses: in an API, catch the exception and return a 500 with a JSON error body
- buildUse error_reporting() and display_errors settings only in development; in production log errors and suppress display
- buildIf the exception is expected (like invalid input), validate before the risky operation to avoid throwing in the first place
- buildFor PHP 7+, use Throwable interface to catch both Exceptions and Errors (e.g., catch (\Throwable $e))
- buildAdd a finally block if cleanup (closing files, releasing locks) is needed regardless of success
A fix you cannot prove is a guess. Close the loop.
- verifiedReproduce the condition that caused the exception (e.g., bad input, missing file) and confirm no fatal error is logged
- verifiedCheck the PHP error log after the fix: grep 'Fatal error' /var/log/php_errors.log should show no new lines
- verifiedTrigger a test exception manually (e.g., throw new Exception('test')) and verify your handler catches it and returns a proper response
- verifiedUse a tool like php -l to lint your files and ensure syntax isn't causing the issue
- verifiedRun unit/integration tests that cover the exception scenario to ensure the catch works
- verifiedMonitor the application health endpoint for HTTP 500s after deployment
Things that make this bug worse or harder to find.
- warningUsing try-catch without actually handling the exception (empty catch block swallows the problem)
- warningDisplaying the exception message directly to the user in production—exposes internals
- warningForgetting to log the exception before handling it—loses debugging info
- warningCatching \Exception but not \Throwable in PHP 7+—Errors (like TypeError) won't be caught
- warningMisplacing the try-catch too deep in the stack—the exception may still go uncaught if not wrapped at the right level
- warningRelying solely on display_errors instead of proper exception handling—only works in development and is insecure in production
- warningSetting a global exception handler but not calling restore_error_handler() appropriately—can cause conflicts with other handlers
Payment Gateway Throws Uncaught Exception on Checkout
Timeline
- 09:15Support reports that checkout fails with white screen for some users
- 09:17Check Laravel log (storage/logs/laravel.log): 'Fatal error: Uncaught Stripe\Exception\CardException'
- 09:20Identify the line: app/Http/Controllers/CheckoutController.php:85 where Stripe::charge() is called
- 09:22Notice that the surrounding code has no try-catch; the exception propagates to Laravel's handler
- 09:25Check the Stripe dashboard: that particular charge attempt had a declined card (insufficient funds)
- 09:30Reproduce locally by using a test card number that triggers a decline
- 09:35Add try-catch around the Stripe::charge() call, catch \Stripe\Exception\CardException, log it, and return a friendly error message
- 09:40Deploy the fix and test with the same test card: now shows 'Your card was declined. Please try another payment method.'
- 09:45Monitor logs for the next hour: no more fatal errors from the checkout endpoint
I was called in after the support team reported that users were hitting a white screen at checkout. They said it happened sporadically, maybe 5% of payments. I checked the Laravel log first and saw the fatal error: 'Uncaught Stripe\Exception\CardException'. The stack trace pointed to CheckoutController@charge, line 85, where we called Stripe::charge() with the token from the frontend.
I immediately reproduced using Stripe's test card that declines (4000000000000002). The exception was thrown, and because there was no try-catch around the Stripe call, it bubbled up to Laravel's exception handler. By default, Laravel logs it and shows a generic 500 error page, which to the user is a white screen. The real problem was that we assumed Stripe would always succeed, but declined cards throw exceptions, not return error responses.
I added a try-catch around the charge call, caught \Stripe\Exception\CardException specifically, and logged the error message. Then I returned a JSON response with a user-friendly message like 'Your card was declined. Please try another payment method.' I also wrapped other Stripe exceptions (ApiConnectionException, InvalidRequestException) to handle network issues gracefully. Tested with multiple test cards, deployed, and confirmed the fatal error disappeared from logs. Lesson: never assume third-party API calls will succeed—always wrap them in try-catch and handle expected exceptions.
Root cause
Missing try-catch around Stripe API call that throws CardException on declined cards, causing an uncaught exception and white screen.
The fix
Wrapped the Stripe::charge() call in a try-catch block, specifically catching \Stripe\Exception\CardException and other Stripe exceptions, logging them, and returning a user-friendly error response.
The lesson
Always wrap external API calls in try-catch blocks. Use specific exception classes to handle different failure modes. Never assume success.
PHP has a hierarchy: Throwable (interface) -> Exception (class) and Error (class). In PHP 7+, Errors (like TypeError, ParseError) are also Throwable but not Exception. So catching \Exception will not catch Errors. To catch everything, use catch (\Throwable $e). This is critical when you use strict types or type hints—a TypeError thrown from a library will become an uncaught fatal if you only catch Exception.
The uncaught exception message includes the fully qualified class name. If you see 'Uncaught Error', that's a PHP Error, not an Exception. For example, calling a method on null triggers a TypeError. Always check whether you need to catch \Throwable or just \Exception. In modern PHP, I recommend catching \Throwable in global handlers unless you specifically want to let Errors propagate.
set_exception_handler() registers a callback that receives any uncaught exception. It runs after the exception is thrown and before script termination. You can log, display a friendly page, or even recover if you're clever. But there are gotchas: the handler itself can throw exceptions (which become fatal), and you cannot catch the exception inside the handler. Also, if you have multiple handlers, only the last one registered takes effect.
In frameworks like Laravel, the handler in App\Exceptions\Handler is registered via the kernel. If you override it, make sure to call parent methods or handle all exception types. A common mistake is forgetting to call parent::render() in the render method, which breaks the framework's default behavior for exceptions you didn't customize.
Xdebug can enhance the stack trace output. Setting xdebug.show_exception_trace=1 makes Xdebug output a stack trace even if the exception is caught (useful for debugging). But in production, keep this off because it outputs HTML and may leak sensitive info. Use xdebug.default_enable=0 to disable Xdebug's default error handling and rely on your own handler.
When you see a stack trace, read from bottom to top. The topmost frame is where the exception was thrown. The bottommost is the entry point. Look for your application code—if the trace goes into vendor/, the issue is likely in how you call a library. Pay attention to arguments in the trace: Xdebug often shows them, and you can spot null values or wrong types.
The best fix is to avoid throwing exceptions in the first place. Validate input before passing to risky functions. For example, check that a file exists before trying to read it, or verify that a database connection is active before querying. Use assert() in development to catch invariants early, but don't rely on it in production (assert is disabled by default).
For database operations, use transactions and handle exceptions per operation. If you expect certain exceptions (e.g., duplicate entry), catch them and handle gracefully. An uncaught exception from a database query often means you didn't wrap the query in a try-catch or the code assumes success.
Laravel: The App\Exceptions\Handler class has a report() method to log exceptions and a render() method to convert exceptions to HTTP responses. If you see an uncaught exception in Laravel, it means the handler's render method either threw or didn't handle that exception class. Customize the handler by adding the exception class to the $dontReport array if you don't want it logged, or implement renderForException().
Symfony: Uses EventDispatcher to listen to kernel.exception events. Uncaught exceptions are caught by the HttpKernel and dispatched. If you see a fatal error, the event listener might not have been registered or it threw an exception itself. Check your services.yaml for the kernel.exception listener tag.
WordPress: WP has a built-in wp_die() function that handles many errors, but it doesn't catch exceptions by default. You can use set_exception_handler('wp_die') but that may not produce a pretty page. Many plugins register their own handler. The common cause is a plugin that throws an exception without a try-catch, often in template files or AJAX handlers.
Frequently asked questions
What is the difference between a PHP Fatal Error and an Uncaught Exception?
A PHP Fatal Error is a category that includes uncaught exceptions. 'Uncaught Exception' specifically means a thrown Exception object that wasn't caught by a try-catch block. It is logged as a fatal error because the script stops. Other fatal errors (like undefined function) are not exceptions. In PHP 7+, uncaught Errors (like TypeError) are also fatal but are not Exception instances—they are Error instances, both implementing Throwable.
How do I find the exact line where the exception was thrown?
Look in the PHP error log for the line that says 'PHP Fatal error: Uncaught Exception ... in /path/to/file.php:123'. The number after the colon is the line number. The stack trace below shows the call chain. The very first line in the stack trace (after the error message) is the throw point. You can also enable xdebug.show_exception_trace=1 to see the trace even for caught exceptions during development.
Can I catch a PHP Fatal Error (uncaught exception) without using try-catch?
Yes, you can use set_exception_handler() to define a callback that runs on any uncaught exception. This is a global catch-all. You can also use register_shutdown_function() to catch fatal errors that aren't exceptions (like parse errors), but that's trickier. For exceptions, the handler receives the exception object. If your handler throws, it becomes a new uncaught exception and causes a second fatal error.
Why does my PHP script show a white screen instead of the error message?
By default, PHP's display_errors is Off in production. The script stops and outputs nothing (or a partial page). The error is written to the error log. To see the error in development, set display_errors = On and error_reporting = E_ALL in php.ini. Never do this in production—it exposes sensitive information. Instead, create a custom error handler that shows a user-friendly message while logging the details.
How do I prevent uncaught exceptions in a REST API?
Wrap your entire request handler (e.g., the controller or route closure) in a try-catch that catches \Throwable. In the catch block, log the exception and return an appropriate HTTP response (e.g., 500 with JSON error body). Also register a global exception handler as a fallback. This ensures that any unexpected exception results in a structured JSON error rather than a blank response or HTML error page.