Heya! I’m Dave. I’m a product designer, engineer, PM, and team lead who works at Automattic.

Custom PHP Error Log (w/ full code)

I built a custom error handling system, which I find extremely handy, so I’m sharing all of the code below. Feel free to use it however you’d like.

I can’t remember where I got the inspiration for this design. I’d love to give them credit if I can find it.

Over the past year, I’ve been slowly building my side project Crafd.com on weekends. As the sole developer, I’ve handled all of the design, front-end and back-end work.

I built Crafd using vanilla PHP and JavaScript, without any frameworks or libraries. It’s been enjoyable to pare back the bloat of modern web development and focus on raw code again.

Testing has never been my forte. That said, I recognize the value of having some sort of validation to quickly catch bugs after deploying changes. Rather than use an external error handling service, I decided to roll my own error handling system as a learning exercise.

I centralized all MySQL, PHP and JavaScript errors into one UI. Below I will share the code I used to create this error handling system.

Preview

Here’s a quick preview of the page in action:

Code

Here is a recap of the code that is used to run this page:

HTML

<pre class="wp-block-syntaxhighlighter-code"><html>
<head>
	<meta name="viewport" content="width=device-width, initial-scale=1">
	<meta http-equiv="content-type" content="text/html; charset=utf-8" />
	<title>Error Dashboard</title>
	<link rel="stylesheet" type="text/css" charset="utf-8"  media="screen, projection" href="/css/crafd-admin.css" />
</head>
<body>
	<div class="backdrop"></div>
	<div class="admin-container">
		<!-- LEFT COLUMN -->
		<div class="admin-left">
			<header>
				<a href="" class="menu">
					<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
						<path d="M3 12H21" stroke="#3E3E3E" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/>
						<path d="M3 6H21" stroke="#3E3E3E" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/>
						<path d="M3 18H21" stroke="#3E3E3E" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/>
					</svg>
				</a>
				<h2>Error Logs</h2>
			</header>
			<div class="log-files">
				<?php foreach ($errors as $key => $error) { ?>
				<div class="log-file" id="log-<?php echo safe($key); ?>" data-key="<?php echo safe($key); ?>">
					<span class="log-file-name"><?php echo safe($error['name']); ?></span>
					<span class="log-file-size"><?php echo safe($error['size']); ?></span>
					<a href="/admin/errors/delete/<?php echo str_replace('.log', '', safe($error['name'])); ?>" class="log-file-delete">
						<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#9E9E9E" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg>
					</a>
				</div>
				<?php } ?>
			</div>
		</div>
		<!-- TABLE -->
		<div class="admin-right">
			<div class="top-bar">
				<div class="top-bar-filters">
					<a href="" class="filter filter-mysql show" data-type="mysql">
						<svg height="15" viewBox="0 0 14 15" width="14" xmlns="http://www.w3.org/2000/svg"><g style="stroke:#9E9E9E;stroke-width:1.333333;fill:none;fill-rule:evenodd;stroke-linecap:round;stroke-linejoin:round" transform="translate(1 1)"><ellipse cx="6" cy="2" rx="6" ry="2"/><path d="m12 6.66666667c0 1.10666666-2.66666667 2-6 2s-6-.89333334-6-2"/><path d="m0 2v9.3333333c0 1.1066667 2.66666667 2 6 2s6-.8933333 6-2v-9.3333333"/></g></svg>
						MySQL
					</a>
					<a href="" class="filter filter-php show" data-type="php">
						<svg height="10" viewBox="0 0 15 10" width="15" xmlns="http://www.w3.org/2000/svg"><g style="stroke:#9E9E9E;stroke-width:1.333333;fill:none;fill-rule:evenodd;stroke-linecap:round;stroke-linejoin:round" transform="translate(1 1)"><path d="m9.33333333 8 3.99999997-4-3.99999997-4"/><path d="m4 0-4 4 4 4"/></g></svg>
						PHP
					</a>
					<a href="" class="filter filter-s3 show" data-type="js">
						<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#9E9E9E" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect><circle cx="8.5" cy="8.5" r="1.5"></circle><polyline points="21 15 16 10 5 21"></polyline></svg>
						S3
					</a>
					<a href="" class="filter filter-js show" data-type="js">
						<svg height="11" viewBox="0 0 14 11" width="14" xmlns="http://www.w3.org/2000/svg"><text fill="#9E9E9E" fill-rule="evenodd" font-family="SFProDisplay-Semibold, SF Pro Display" font-size="12" font-weight="500" letter-spacing=".0873409128"><tspan x="0" y="10">JS</tspan></text></svg>
						JavaScript
					</a>
				</div>
				<div class="top-bar-search">

				</div>
			</div>
			<!-- TABLE -->
			<table class="errors" cellpadding="0" cellspacing="0" border="0">
				<thead>
					<tr>
						<th></th>
						<th>Time</th>
						<th>Message</th>
						<th>File</th>
						<th>Line</th>
						<th>User</th>
						<th>URL</th>
					</tr>
				</thead>
				<tbody></tbody>
			</table>
		</div>
	</div>
	<!-- JAVASCRIPT -->
	<script>
		var errors = <?php echo json_encode(safe($errors)); ?>;
	</script>
	<a href="/js/crafd-utility.js">/js/crafd-utility.js</a>
	<a href="heading%20">/js/admin/error.js/script?php%20require_once(PUBPATH.'templates/error-row.php');%20?/body/html/pre!--%20/wp:syntaxhighlighter/code%20--!--%20wp:heading%20</a></pre>

PHP (/api/error/)

The following code processed JS errors from window.onerror:

<?php if (!defined('COREPATH')) exit('No direct script access allowed');

// Prep post variables
$type = 'js';
$message = (isset($_POST['message'])) ? $_POST['message'] : '';
$file = (isset($_POST['file'])) ? $_POST['file'] : '';
$line = (isset($_POST['line'])) ? $_POST['line'] : '';
$url = (isset($_POST['url'])) ? $_POST['url'] : '';

// Pass to error handler
return error($type, $message, $file, $line, $url);

/* CLOSE DB & SESSIONS ------------------------------------------- */
closeDbAndSessions();

PHP (Controller)

<?php if (!defined('APPPATH')) exit('No direct script access allowed');

/* ADMIN CHECK ------------------------------------------- */
// Code here to prevent unauthorized access

/* CHECK IF DELETE ------------------------------------------- */
if (isset($glob['route'][2]) && $glob['route'][2] == 'delete') {
	$file = $glob['route'][3] . '.log';
	$path = APPPATH . 'errors/';
	if (file_exists($path.$file)) {
		unlink($path.$file);
	}
	return redirect('admin/errors');
}

/* VARS ------------------------------------------- */
$errors = [];

/* LOAD FILESYSTEM FUNCTION ------------------------------------------- */
require_once(COREPATH.'libraries/Filesystem.php');

/* PREP LOG FILES ------------------------------------------- */
$path = APPPATH.'errors/';
$csv_files = filesystem_directory_map($path);

/* ITERATE OVER LOG FILES ------------------------------------------- */
for ($i = 0; $i < count($csv_files); $i++) {
	// Get file name
	$file_name = $csv_files[$i];
	$log_file = $path . $file_name;
	$errors[$i]['name'] = $file_name;
	// Get file contents
	$file_contents = file_get_contents($log_file);
	// Get file size
	$file_size = filesystem_filesize($log_file);
	$errors[$i]['size'] = $file_size;
	// Loop through each line
	$lines = explode("\n", $file_contents);
	// Loop through each line
	$errors[$i]['lines'] = [];
	foreach ($lines as &$line) {
		// If line is empty, skip it
		if (empty($line)) {
			continue;
		}
		// Repopulate line breaks
		$line = str_replace('~~', "<br>", $line);
		$parts = explode('|', $line);
		// If parts is not 7 parts, skip it
		if (count($parts) != 7) {
			continue;
		}
		$errors[$i]['lines'][] = [
			'type' => $parts[0],
			'time' => $parts[1],
			'message' => $parts[2],
			'file' => $parts[3],
			'line' => $parts[4],
			'username' => $parts[5],
			'url' => $parts[6]
		];
	}
}
// Sort by date
asort($errors);

/* LOAD VIEW ------------------------------------------- */
if (isset($glob['view'])) {
	require_once($glob['view']);
}

/* CLOSE DB & SESSIONS ------------------------------------------- */
closeDbAndSessions();

PHP (Filesystem.php)

<?php if (!defined('COREPATH')) exit('No direct script access allowed');

function filesystem_directory_map($sourceDir, $directoryDepth = 0) {
	try {
		
		if (!is_dir($sourceDir)) {
			return false;
		}

		$fp = opendir($sourceDir);

		$fileData  = [];
		$newDepth  = $directoryDepth - 1;
		$sourceDir = rtrim($sourceDir, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR;

		while (false !== ($file = readdir($fp))) {
			// Remove '.', '..'
			if ($file === '.' || $file === '..') {
				continue;
			}

			if (is_dir($sourceDir . $file)) {
				$file .= DIRECTORY_SEPARATOR;
			}

			if (($directoryDepth < 1 || $newDepth > 0) && is_dir($sourceDir . $file)) {
				$fileData[$file] = directory_map($sourceDir . $file, $newDepth);
			} else {
				$fileData[] = $file;
			}
		}

		closedir($fp);

		return $fileData;
	} catch (Throwable $e) {
		return [];
	}
}

function filesystem_filesize($log_file) {
	$size_in_bytes = filesize($log_file);
	$size_in_kb    = $size_in_bytes / 1024;
	$size_in_megabytes = $size_in_bytes / 1024 / 1024;
	// If less than 1kb, return in bytes
	if ($size_in_kb < 1) {
		return $size_in_bytes . ' B';
	}
	// If less than 1mb, return in kb
	if ($size_in_megabytes < 1) {
		return round($size_in_kb, 2) . ' KB';
	}
	// Else return in mb
	return round($size_in_megabytes, 2) . ' MB';
}

PHP (Bootstrap.php)

This code runs in my bootstrap file to kickstart the app on every page.

/* ERROR TRACKING ------------------------------------------- */
require_once(COREPATH.'libraries/Error.php');
// Set default error function for PHP
set_error_handler('php_error_handler');
// Catch fatal PHP errors
register_shutdown_function('php_fatal_handler');

PHP (Error.php)

<?php if (!defined('APPPATH')) exit('No direct script access allowed');

function error($type, $message, $file='', $line='', $url='') {
	$year = date("y");
	$month = date("m");
	$day = date("d");
	$path = APPPATH.'errors/'.$year.'-'.$month.'-'.$day.'.log';
	// Prep file path
	if ($file === null) {
		$file = ''; // Or some other appropriate default value
	}
	$file = str_replace(ROOTPATH, '', $file);
	$file = str_replace(BASEURL, 'public/', $file);
	// Prep URL
	$url = str_replace(BASEURL, '', $url);
	$server_uri = isset($_SERVER['REQUEST_URI']) ? $_SERVER['REQUEST_URI'] : '';
	if (isset($server_uri) && substr($server_uri, 0, 1) == '/') {
		$server_uri = substr($server_uri, 1);
	}	
	/* Prep data --------------------------------- */
	$error_data = [
		'type' => $type,
		'time' => date('H:i:s'),
		'message' => $message,
		'file' => $file,
		'line' => $line,
		'username' => (!empty($_SESSION['username'])) ? $_SESSION['username'] : '',
		'url' => (!empty($url)) ? $url : $server_uri
	];
	// Ensure that no values in $error_data contain a |
	$error_data = array_map(function($value) {
		return is_null($value) ? $value : str_replace('|', '', $value);
	}, $error_data);
	// format log message into single string concatenated by |
	$error_log = implode('|', $error_data);
	// Remove all new lines
	$error_log = trim(preg_replace('/\s\s+/', '~~', $error_log));
	// Replace new lines with space
	$error_log = str_replace("\r", '~~', $error_log);
	$error_log = str_replace("\n", '~~', $error_log);
	// Add new line to the end of the log message
	$error_log = $error_log . PHP_EOL;
	/* Log the error --------------------------------- */
	file_write($path, $error_log);
}

function php_error_handler($errno, $errstr, $errfile, $errline) {
	$message = $errno . ' - ' . $errstr;
	return error('php', $message, $errfile, $errline);
}

function php_fatal_handler() {
	$errfile = '';
	$errstr  = 'Fatal error';
	$errno   = E_CORE_ERROR;
	$errline = 0;

	$error = error_get_last();

	if($error !== NULL) {
		$errno   = $error["type"];
		$errfile = $error["file"];
		$errline = $error["line"];
		$errstr  = $error["message"];
		$message = $errno . ' - ' . $errstr;

		error('php', $message, $errfile, $errline);
	}
}

PHP (MySQL Error Handling)

Finally, this code sits in my MySQL library catching any errors that take place:

private function ExceptionLog($message , $sql='') {

	$exception  = 'Unhandled Exception. <br />';
	$exception .= $message;
	$exception .= "<br /> You can find the error back in the log.";

	if(!empty($sql)) {
		# Add the Raw SQL to the Log
		$message .= "\r\nRaw SQL : "  . $sql;
	}
		# Log error to dashboard
		error('mysql', $message);

		# Write into log
		$this->log->write($message);
		
		# Email error
		//mail($this->settings['error_email'], 'Crafd DB Error', $exception, 'designpro@gmail.com');

	return $exception;
}	

And I’ve got a try/catch on connection (Note: I’ve redacted the rest of the function):

private function Connect($db_config) {
	try {
		# Code to attempt here
	}
	catch (PDOException $e) {
		# Write into log
		echo $this->ExceptionLog($e->getMessage());
		die();
	}
}

Lastly, another try/catch on execution:

private function Execute($query,$parameters = "") {
	try {
		# Code to attempt here
	}
	catch (PDOException $e) {
		# Write into log and display Exception
		echo $this->ExceptionLog($e->getMessage(), $query );
		die();
	}
}

2 responses to “Custom PHP Error Log (w/ full code)”

  1. alexmigf Avatar
    alexmigf

    Do you have a repository of this?

    Liked by 1 person

    1. Dave Martin Avatar

      Not open. The project is a private repo.

      Like

Leave a comment