Error handling separates professional plugins from fragile ones. A plugin that crashes on error, exposes sensitive debug info, or fails silently without logging becomes a support nightmare. Users expect plugins to fail gracefully, provide helpful messages, and recover when possible.
WordPress has built error handling mechanisms that many plugin developers underutilize. The WP_Error class is the WordPress way to represent errors. Try/catch blocks let you handle exceptions cleanly. wp_die() and die() have specific purposes. Error logging can be enabled for debugging without exposing issues to site visitors. Admin notices communicate problems to administrators without breaking the frontend.
The difference between a plugin that users love and one they abandon often comes down to error handling. When something goes wrong, does the user understand what happened? Can they fix it? Can an admin help troubleshoot? Or does the site go white and leave everyone confused?
This guide walks you through WordPress error handling patterns, showing you antipatterns to avoid and proper patterns to implement. We'll cover WP_Error, try/catch blocks, logging strategies, and graceful degradation—everything you need to build plugins that users trust.
Table of Contents
- Understanding WordPress Error Handling
- WP_Error Class Fundamentals
- Try/Catch Patterns for Exceptions
- wp_die() vs die() vs exit()
- Error Logging and Debugging
- Graceful Degradation Strategies
- Admin Notices for Error Communication
- Frequently Asked Questions
Understanding WordPress Error Handling
WordPress runs on PHP, which has multiple error handling mechanisms: errors, exceptions, and return values. Understanding which to use when is crucial for robust plugin error handling.
Errors in PHP are raised by the engine itself—undefined variables, type mismatches, division by zero. Most errors in modern WordPress plugins should be prevented through type hints and validation, not caught.
Exceptions are thrown by functions and caught by code. They're ideal for representing business logic failures: "payment processing failed," "API returned an error," "database transaction couldn't complete."
Return Values can represent either success or failure. This is the WordPress tradition: many WordPress functions return WP_Error on failure and the expected result on success.
The Three Layers of Error Handling
Professional plugins implement error handling at three distinct layers. Prevention layer uses type hints, validation, and defensive programming to catch problems before they become errors. Recovery layer provides mechanisms to handle problems gracefully when they do occur, using WP_Error, exceptions, and fallback logic. Communication layer ensures that when problems happen, relevant people know about them—administrators through error logs and notices, developers through debug output, users through informative messages. Each layer is critical. Prevention catches the most errors and is the most efficient. Recovery ensures failures don't cascade into system-wide problems. Communication ensures that when prevention fails, appropriate parties can respond. Many WordPress plugins skip the communication layer entirely, leading to silent failures where administrators don't know something is wrong until users complain.
The Philosophy Behind WordPress Error Handling
WordPress has a specific philosophy about error handling that differs from some other frameworks. Rather than using exceptions as the primary error mechanism, WordPress prefers return values—functions return WP_Error on failure and the expected result on success. This approach has advantages: functions are more predictable (you always know what you're getting), integration with WordPress core is simpler, and the learning curve is gentler for developers new to WordPress. However, it requires discipline—you must always check for WP_Error before using the return value. Many security vulnerabilities and crashes result from developers forgetting this check.
Here's the hierarchy of WordPress error handling approaches:
- Prevention (best): Use type hints and validation to prevent errors before they occur
- Return value inspection (WordPress-style): Check for
WP_Errorinstances - Exception handling (try/catch): Catch exceptions from third-party libraries
- Error logging (fallback): Log unhandled errors for debugging
- User-facing messages (last resort): Display admin notices when things go wrong
The goal is catching problems as early as possible—at validation time, not runtime. But when runtime problems do occur, you need solid error handling.
WP_Error Class Fundamentals
WP_Error is WordPress's native error object. It replaces manual error handling patterns and provides a consistent interface across WordPress core and plugins.
// Creating a WP_Error
$error = new WP_Error(
'invalid_user_id',
'The user ID must be a positive integer.',
[ 'status' => 400 ]
);
// Creating with additional data
$error = new WP_Error(
'database_error',
'Failed to save user preferences.',
[
'user_id' => 42,
'field' => 'theme_preference',
]
);
// Check if a result is an error
$result = my_function_that_may_fail();
if ( is_wp_error( $result ) ) {
$error_message = $result->get_error_message();
$error_code = $result->get_error_code();
$error_data = $result->get_error_data();
// Handle the error
}
Here's a practical WordPress error handling example:
class User_Profile_Manager {
/**
* Update a user's profile information.
*
* @param int $user_id The user ID.
* @param array $data The profile data to update.
*
* @return int|WP_Error The user ID on success, WP_Error on failure.
*/
public function update_profile( int $user_id, array $data ) {
// Validate user exists
$user = get_userdata( $user_id );
if ( ! $user ) {
return new WP_Error(
'user_not_found',
sprintf( 'User %d does not exist.', $user_id ),
[ 'user_id' => $user_id ]
);
}
// Validate permissions
if ( ! current_user_can( 'edit_user', $user_id ) ) {
return new WP_Error(
'insufficient_permissions',
'You do not have permission to edit this user.',
[ 'user_id' => $user_id ]
);
}
// Validate input data
if ( isset( $data['email'] ) && ! is_email( $data['email'] ) ) {
return new WP_Error(
'invalid_email',
'The email address is not valid.',
[ 'email' => $data['email'] ]
);
}
// Attempt the update
$result = wp_update_user( array_merge(
[ 'ID' => $user_id ],
$data
) );
if ( is_wp_error( $result ) ) {
// WordPress itself returned an error, pass it through
return $result;
}
return $result;
}
}
// Usage
$manager = new User_Profile_Manager();
$result = $manager->update_profile( 42, [ 'email' => '[email protected]' ] );
if ( is_wp_error( $result ) ) {
// Handle error: log it, notify admin, return to user
error_log( $result->get_error_message() );
} else {
// Success: user updated
}
Why use WP_Error instead of throwing exceptions? WordPress functions don't throw exceptions—that's not the WordPress way. They return WP_Error. By following this pattern, your plugin integrates seamlessly with WordPress APIs and user expectations.
The WP_Error Data Parameter: Additional Context
Many developers use WP_Error only for the error code and message, missing the power of the data parameter. The third parameter to WP_Error is a data array that stores arbitrary context about the error. This context is invaluable for debugging and logging. When you return an error from an API, include the HTTP status code in data. When rejecting a user action, include the user ID and timestamp. When a database query fails, include the query and affected table. This information becomes crucial when troubleshooting production issues. You'll thank yourself later when you're debugging a user report and the error log contains all the context you need, rather than forcing you to reproduce the issue from scratch. Additionally, the data parameter allows you to pass structured information to error handlers that might want to take different actions based on error context, not just the error code.
Proper Wrapping of WordPress Function Results
A pattern you'll use frequently is calling a WordPress function that might return WP_Error, then wrapping that result. The pattern is to check the result, and if it's a WP_Error, either return it or add additional context:
public function save_post_data( int $post_id, array $data ) {
// Validate email field
if ( ! is_email( $data['email'] ?? '' ) ) {
return new WP_Error(
'invalid_email',
'Email address is not valid.',
[ 'field' => 'email', 'value' => $data['email'] ?? '' ]
);
}
// Call WordPress function that might return WP_Error
$result = wp_update_post( [
'ID' => $post_id,
'post_title' => $data['title'],
'post_content' => $data['content'],
] );
if ( is_wp_error( $result ) ) {
// WordPress returned an error—wrap it with our context
return new WP_Error(
'post_update_failed',
'Could not update post: ' . $result->get_error_message(),
[
'post_id' => $post_id,
'original_error' => $result->get_error_code(),
]
);
}
return $result;
}
This pattern ensures that your function always returns consistent error types and context, making it predictable for callers.
Try/Catch Patterns for Exceptions
While WordPress favors return-value error handling, you'll encounter exceptions when using third-party libraries, database queries, or your own custom exception classes.
class Payment_Processor {
/**
* Process a payment through an external API.
*
* @throws Payment_Exception When the API call fails.
* @throws Network_Exception When the network call fails.
*/
public function process_payment( int $amount, string $currency ): array {
try {
// This external library throws exceptions
$response = $this->stripe_client->charges->create( [
'amount' => $amount,
'currency' => $currency,
] );
return [
'success' => true,
'transaction_id' => $response->id,
];
} catch ( Card_Error $e ) {
// Card declined
throw new Payment_Exception(
'Payment declined: ' . $e->getMessage(),
Payment_Exception::CARD_DECLINED
);
} catch ( Rate_Limit_Error $e ) {
// API rate limited
throw new Payment_Exception(
'Payment service is busy, please try again later.',
Payment_Exception::RATE_LIMITED
);
} catch ( Exception $e ) {
// Unexpected error
throw new Payment_Exception(
'Payment processing failed: ' . $e->getMessage(),
Payment_Exception::GENERIC_ERROR
);
}
}
}
// Custom exception class for your plugin
class Payment_Exception extends Exception {
const CARD_DECLINED = 1;
const RATE_LIMITED = 2;
const GENERIC_ERROR = 3;
}
// Usage in WordPress action
add_action( 'wp_ajax_process_payment', function() {
try {
$processor = new Payment_Processor();
$result = $processor->process_payment( 5000, 'USD' );
wp_send_json_success( $result );
} catch ( Payment_Exception $e ) {
// Business logic error: communicate to user
wp_send_json_error( [
'message' => $e->getMessage(),
'code' => $e->getCode(),
] );
} catch ( Exception $e ) {
// Unexpected error: log and notify
error_log( 'Unexpected payment error: ' . $e->getMessage() );
wp_send_json_error( [
'message' => 'An unexpected error occurred. Please contact support.',
] );
}
} );
The key principle: catch exceptions you expect and know how to handle. Let unexpected exceptions propagate up.
Exception Hierarchy and Selective Catching
When using try/catch, create a hierarchy of custom exceptions so you can catch specific error types. Generic catch ( Exception $e ) blocks hide different error types under one handling path, making debugging difficult and leading to poor error handling decisions. A specific exception hierarchy lets you handle different problems differently. Card declined errors should communicate to the user that their payment failed. Rate limit errors should tell the user to try again later. System errors should log details and show a generic message. By catching specific exceptions in order from most specific to least specific, you implement different recovery strategies for different problems. This approach makes your error handling more robust and informative.
Converting Exceptions to WP_Error at System Boundaries
When calling external libraries that throw exceptions, convert those exceptions to WP_Error at the WordPress API boundary. This maintains consistency throughout your plugin—callers of your functions always get back either the expected result or a WP_Error, never raw exceptions. This conversion point is where you abstract away the details of external libraries, making your code more testable and maintainable. If you later switch payment providers, your code's API doesn't change—only the conversion logic changes.
Ensure your plugin handles errors gracefully. Scan your plugin with WP HealthKit to identify error handling gaps, deprecated error patterns, and security issues. Our automated audit catches error-handling problems that manual review might miss.
wp_die() vs die() vs exit()
WordPress provides wp_die() as an alternative to PHP's die() and exit(). Understanding when to use each is important for error handling error handling best practices.
// die() or exit(): immediate termination, no cleanup
// Avoid in most cases—no opportunity for cleanup or user feedback
die( 'Something went wrong' );
// wp_die(): WordPress-aware termination with proper formatting
// Use for fatal errors that should stop execution
wp_die(
'Database connection failed. Please contact your hosting provider.',
'Database Error',
[ 'response' => 500 ]
);
// Graceful return: best practice for most errors
// Let the calling code decide what to do
if ( $user_count > $limit ) {
return new WP_Error(
'limit_exceeded',
'User limit exceeded.'
);
}
wp_die() is appropriate only for truly fatal errors—database connection failures, critical file not found errors, security violations that should halt execution immediately. Most plugin errors should return WP_Error and let the calling code decide.
Here's an example distinguishing between fatal and non-fatal errors:
class Plugin_Initializer {
public function initialize() {
// Fatal: required configuration missing
if ( ! defined( 'MY_PLUGIN_CONFIG' ) ) {
wp_die(
'Plugin configuration file is missing. ' .
'Please contact your administrator.',
'Configuration Error'
);
}
// Non-fatal: feature not available
if ( ! function_exists( 'json_encode' ) ) {
add_action( 'admin_notices', function() {
echo '<div class="notice notice-error"><p>' .
'This plugin requires JSON support.' .
'</p></div>';
} );
return false; // Disable the feature
}
// Non-fatal: permission denied
if ( ! current_user_can( 'manage_options' ) ) {
return new WP_Error(
'insufficient_permissions',
'You do not have permission to access this feature.'
);
}
}
}
Error Logging and Debugging
WordPress supports error logging through the WP_DEBUG constant and a debug log file. Enabling logging is critical for production debugging. Many WordPress sites never enable debugging at all, making it impossible to identify why errors occur. When users report "my plugin stopped working," without debug logging you have no way to see what actually failed. Production debugging requires log files that capture every error, with enough context to understand what led to the failure.
Setting Up WordPress Debugging Properly
The key is enabling logging without exposing errors to visitors. Set WP_DEBUG and WP_DEBUG_LOG to true, but WP_DEBUG_DISPLAY to false. This writes all errors to /wp-content/debug.log where only administrators and the hosting provider can see them, while hiding errors from frontend visitors. You might also want to log deprecated function notices so you catch compatibility issues with new WordPress versions before they become problems. Debug logging should be enabled on all sites—development, staging, and production. The performance impact is negligible, but the debugging information is invaluable.
// In wp-config.php, enable debugging
define( 'WP_DEBUG', false ); // Don't show errors to visitors
define( 'WP_DEBUG_LOG', true ); // Log errors to wp-content/debug.log
define( 'WP_DEBUG_DISPLAY', false ); // Don't display errors to users
// In your plugin, use error_log()
error_log( 'Payment processing started for user ' . $user_id );
// Log errors with context
error_log( sprintf(
'Payment failed for user %d: %s',
$user_id,
$error->get_error_message()
) );
// Log data as JSON for easier parsing
error_log( json_encode( [
'event' => 'payment_failed',
'user_id' => $user_id,
'amount' => $amount,
'error' => $error->get_error_message(),
'timestamp' => current_time( 'mysql' ),
] ) );
Beyond Basic Logging: Production Monitoring
For production sites with significant traffic, basic file logging isn't enough. Debug.log can grow huge and become unwieldy to search through. Consider integrating with external logging services that provide searching, filtering, and alerting. When critical errors occur, you want to be notified immediately so you can respond, not discover the issue a week later when a user complains. You might log normal operations at INFO level, concerning situations at WARNING level, and serious failures at ERROR level. Then configure alerts so critical ERROR-level events notify on-call staff immediately, while WARNING-level events are logged for later review. This tiered approach ensures you respond to serious problems quickly while not getting overwhelmed by informational logs.
Structured logging makes debugging easier:
class Logger {
private static function log( string $level, string $message, array $context = [] ) {
$entry = [
'timestamp' => current_time( 'mysql' ),
'level' => $level,
'message' => $message,
'context' => $context,
'trace' => wp_debug_backtrace_summary(),
];
error_log( json_encode( $entry ) );
}
public static function info( string $message, array $context = [] ) {
self::log( 'INFO', $message, $context );
}
public static function warning( string $message, array $context = [] ) {
self::log( 'WARNING', $message, $context );
}
public static function error( string $message, array $context = [] ) {
self::log( 'ERROR', $message, $context );
}
}
// Usage
Logger::info( 'User profile updated', [ 'user_id' => 42 ] );
Logger::error( 'Payment failed', [ 'user_id' => 42, 'reason' => 'card_declined' ] );
Graceful Degradation Strategies
When things go wrong, graceful degradation lets your plugin continue functioning with reduced functionality rather than breaking completely. The alternative to graceful degradation is catastrophic failure—the entire plugin stops working, or worse, the entire site stops working. Graceful degradation is what separates professional plugins from fragile ones. A professional plugin checks for required extensions and features at startup, disables features that aren't available, and continues operating with what is available. This way, if a site doesn't have the curl extension, your plugin still works for features that don't require curl—they just silently disable analytics without breaking the whole site.
Feature Detection and Graceful Disabling
The pattern is to detect what's available at initialization time, store which features are available, and check availability before attempting to use those features. This approach prevents runtime failures—you discover missing dependencies early and adapt accordingly. You might disable features in several scenarios: when required PHP extensions are missing (curl, json, openssl), when WordPress features are unavailable (custom post types not registered, plugins not active), when site configuration is missing (API keys not configured, database tables not created), or when resources are constrained (running low on memory or disk space).
Recovery from Transient Failures
Some failures are transient—a network error, a temporarily-down API, a database lock. These shouldn't disable features permanently, only temporarily. Implement retry logic with exponential backoff. Log the failure and check again on the next request. If the issue persists across multiple requests, then disable the feature. This approach handles occasional network glitches without impacting user experience for temporary problems. For example, if your plugin fetches data from an external API and the API is temporarily down, your first request fails and you log it. The next request tries again. If it succeeds, great—the API came back up. If it fails again, you know it's a real problem and can handle it accordingly (disable the feature, show a notice, etc.).
Partial Success Handling
Sometimes operations partially succeed—you successfully save most of a user's data but fail on one field, or you process 950 out of 1000 items in a batch. Rather than treating partial success as complete failure, communicate what succeeded and what failed. The user can then understand what happened and what to do next. They might retry the failed items, or manually fix the one failed field. Return error objects that describe which operations failed and why, not just a generic "operation failed" message.
class Feature_Manager {
private $features = [
'analytics' => true,
'reporting' => true,
'export' => true,
];
public function initialize() {
// Check if required libraries are available
if ( ! extension_loaded( 'json' ) ) {
// JSON not available: disable JSON-dependent features
$this->features['analytics'] = false;
$this->features['reporting'] = false;
$this->features['export'] = false;
Logger::warning( 'JSON extension not available, disabling features' );
}
// Check if optional APIs are available
if ( ! extension_loaded( 'curl' ) ) {
// cURL not available: disable API-dependent features
$this->features['analytics'] = false;
Logger::warning( 'cURL not available, disabling analytics' );
}
}
public function is_feature_available( string $feature ): bool {
return $this->features[ $feature ] ?? false;
}
public function get_analytics( int $post_id ) {
if ( ! $this->is_feature_available( 'analytics' ) ) {
return null; // Return null when feature unavailable
}
// Fetch analytics
}
}
// In your template/shortcode
$analytics = $manager->get_analytics( $post_id );
if ( $analytics ) {
echo $analytics;
} else {
echo '<!-- Analytics unavailable -->';
}
Admin Notices for Error Communication
Users need to know when something goes wrong. Admin notices are the proper way to communicate with site administrators.
add_action( 'admin_notices', function() {
// Check for plugin initialization error
if ( get_transient( 'my_plugin_init_error' ) ) {
$error = get_transient( 'my_plugin_init_error' );
?>
<div class="notice notice-error is-dismissible">
<p>
<strong>My Plugin Error:</strong>
<?php echo wp_kses_post( $error ); ?>
</p>
</div>
<?php
delete_transient( 'my_plugin_init_error' );
}
} );
// When an error occurs, set a transient
add_action( 'init', function() {
$result = check_plugin_requirements();
if ( is_wp_error( $result ) ) {
set_transient(
'my_plugin_init_error',
$result->get_error_message(),
3600 // Show for 1 hour
);
}
} );
Better: provide actionable notices with links to fix the problem:
add_action( 'admin_notices', function() {
// Check if configuration is set
if ( ! get_option( 'my_plugin_api_key' ) ) {
$settings_url = admin_url( 'options-general.php?page=my-plugin-settings' );
?>
<div class="notice notice-warning is-dismissible">
<p>
<strong>My Plugin:</strong>
API key not configured.
<a href="<?php echo esc_url( $settings_url ); ?>">
Configure now
</a>
</p>
</div>
<?php
}
} );
Additional Resources
Frequently Asked Questions
Should I use WP_Error or exceptions in my plugin?
Use WP_Error by default—it's the WordPress way and what users expect. Use exceptions for your own custom code and when calling third-party libraries that throw them. Convert exceptions to WP_Error at the WordPress API boundary.
How do I debug errors in production without exposing them to visitors?
Enable WP_DEBUG_LOG in wp-config.php to write errors to /wp-content/debug.log. Keep WP_DEBUG_DISPLAY set to false so errors don't display to visitors. Check the debug log regularly for issues.
What's the difference between fatal and non-fatal errors in WordPress?
Fatal errors (database down, missing critical file) should halt execution immediately using wp_die(). Non-fatal errors (missing optional data, permission denied) should return WP_Error and let the calling code decide what to do.
How should I handle errors in REST API endpoints?
Return WP_Error from your REST controller callback. WordPress automatically converts it to a proper JSON error response with correct HTTP status codes.
add_rest_route( 'myapp/v1', '/profile', [
'callback' => function( WP_REST_Request $request ) {
$user_id = $request->get_param( 'user_id' );
if ( ! $user_id ) {
return new WP_Error(
'missing_user_id',
'User ID is required.',
[ 'status' => 400 ]
);
}
return [ 'success' => true ];
},
] );
Should I add try/catch blocks in WordPress hooks?
Only if you're calling code that throws exceptions. WordPress action/filter callbacks don't throw—they return values or modify global state. Use try/catch when calling external libraries, then handle the exception appropriately.
How can I test error handling in my plugin?
Write unit tests that verify your functions return proper WP_Error instances with correct error codes. Use mocking to simulate API failures and test that your exception handling works correctly.
public function test_update_profile_returns_error_for_invalid_user() {
$manager = new User_Profile_Manager();
$result = $manager->update_profile( 9999, [ 'email' => '[email protected]' ] );
$this->assertTrue( is_wp_error( $result ) );
$this->assertEquals( 'user_not_found', $result->get_error_code() );
}
Conclusion
Solid error handling is the foundation of plugins users trust. By using WP_Error, try/catch blocks, proper logging, and graceful degradation, you create plugins that fail gracefully and communicate clearly when something goes wrong.
The WordPress error handling pattern is simple: validate input early, return WP_Error when something goes wrong, log errors for debugging, and communicate problems to users through admin notices. This approach keeps your code clean and your users informed.
Make sure your plugin error handling is solid. Upload your plugin to WP HealthKit to get a comprehensive audit of your error handling patterns, logging strategy, and exception management. Our automated analysis identifies antipatterns, insecure error practices, and debugging issues before they reach production.
For more on plugin quality, explore our GitHub Actions CI/CD guide to automate testing of your error handling, or check out the WP HealthKit ecosystem to see how other plugins handle errors and what best practices look like in the wild.