CI4 + PHP 8.1: Finally block not executed when mysqli_sql_exception occurs (but works with register_shutdown_function)

I’m working on a CodeIgniter 4 project (CI: 4.4.3 production mode | PHP: 8.1.32), and I’m running into a strange issue where my finally block never executes if a database error happens during execution — even though I’m catching \Throwable (and doesn’t remove the lock file).

But when I uncomment register_shutdown_function(), it works and cleans up the lock file.

Here’s a minimal reproduction:

run() method in CronRunner

namespace App\Models;

class CronRunner extends CI_Model {

  public function run($data) {

    $result = "OK";

    $executedAt = date('Y-m-d H:i:s'); // for logs
    $executedAtUnix = time();         // for duration calc

    $lockFile = WRITEPATH . 'cache/cronRunner.lock';

    if (file_exists($lockFile))
      return;

    file_put_contents($lockFile, ENVIRONMENT);

    // This works — when it's enabled, the file gets removed
    // register_shutdown_function(function () use ($lockFile) {
    //   if (file_exists($lockFile)) {
    //     unlink($lockFile);
    //   }
    // });

    try {
      $this->notifyLongRunningTask();
    } catch (\Throwable $e2) {
      $result = $e2->getMessage();
      log_message('error', (string) $result);
    } finally {
      log_message('error', 'FINALLY BLOCK REACHED');
      if (file_exists($lockFile)) {
        unlink($lockFile);
      }
    }

    return ['result' => $result];
  }
}

Here’s notifyLongRunningTask()

function notifyLongRunningTask() {
  $PushSubscriptions = new PushSubscriptions();
  $PushSentLog = new PushSentLog();

  $db = \Config\Database::connect();

  $sql = "
    SELECT hs.id as hourly_id, date_started, time_started, hs.organization_id, hs.user_created, c.name Client, p.client_id
    FROM HourlySheet hs
      JOIN ToDo t ON hs.todo_id = t.id
      JOIN Project p ON t.project_id = p.id
      JOIN Client c ON p.client_id = c.id
    WHERE time_finished IS NULL
  ";

  $query = $db->query($sql);

  foreach ($query->getResult() as $row) {
    $startedAt = strtotime($row->date_started . ' ' . $row->time_started);
    $now = time();

    // This line causes the issue
    $PushSentLog_res = $PushSentLog->select(" AND user_created = {$row->user_created} AND name = 'notifyLongRunningTask' ORDER BY id DESC LIMIT 1 ");
    
    // ...
  }
}

The actual query error

mysqli_sql_exception: Column 'name' in WHERE is ambiguous

And the query is triggered from within the select() method of our CI_Model-based class. That method includes this logic:

if (!$query) {
  $error = $db->error()["message"];
  $baseClass = get_parent_class($this);
  $this->log_it($error, $baseClass . " " . __FUNCTION__ . ' DB Error');
  return ['result' => (string) $error];
}

So the SQL error is:

  • Caught
  • Logged
  • Returned as part of ['result' => '...']

But still — somehow — this causes PHP/CI4 to terminate execution before finally runs, and the lock file is not removed.


What works

If I uncomment the register_shutdown_function(), then the lock file is removed as expected.

This tells me the script is being shut down unexpectedly, despite \Throwable being caught, and no exit() or fatal visible.


Why is this happening?

  • We’re on PHP 8.1.32 and CI4 4.4.3
  • DBDebug is set to false (production mode)
  • The query fails with a typical ambiguous column error
  • It is handled inside select() (custom function)
  • But the finally block from the run() method is never reached
  • The shutdown function does get called

Question

Why is the finally block not executed, even though the exception is handled and no uncaught fatal is shown?

Is this a PHP 8.1 + CI4 side effect?
Or are certain internal CI4 behaviors forcing a shutdown?

Any insight from CI4 internals or PHP changes in 8.1+ would be much appreciated.

You’re encountering a situation where the finally block does not execute, even though:

  • You’re catching \Throwable
  • No uncaught fatal error appears
  • The shutdown function does get called

This is unusual — but based on your description, here’s what’s really happening.


Straight Answer:

Your finally block isn’t running because somewhere deeper in your call stack (likely inside your custom select() or CI_Model) an internal fatal error or memory error occurs that bypasses normal PHP exception handling.

This is not a CI4-specific bug, but a combination of:

  1. PHP not throwing an exception in certain internal errors
  2. Custom CI4 model code potentially throwing or handling errors incorrectly (silently triggering shutdown)

Why the finally block is skipped

1. PHP fatal errors (non-catchable)

If something deep in your code — for example:

  • Accessing properties on non-objects
  • Exhausting memory
  • Calling a method on null
  • Certain mysqli_* errors

… causes a fatal error that is not catchable (even with \Throwable), then the engine will abort immediately, and only register_shutdown_function() will be invoked.

Even though you’re catching \Throwable around notifyLongRunningTask(), the error might not be thrown from that layer, or might be handled poorly inside select().

PHP 8.1 is more aggressive than earlier versions about promoting some warnings/notices to Error, but some internals (e.g., mysqli SQL parsing errors) can still cause fatal termination, especially if there’s no proper error checking.


2. Custom select() logic may hide the real failure

This code in your select():

if (!$query) {
  $error = $db->error()["message"];
  $baseClass = get_parent_class($this);
  $this->log_it($error, $baseClass . " " . __FUNCTION__ . ' DB Error');
  return ['result' => (string) $error];
}

Might return early, but if $PushSentLog->select(...) is misused or chained incorrectly, it could result in invalid object access or return an unexpected type. That could cause a fatal error in the next line, which you’re not catching because it’s outside your try block — or it propagates before PHP reaches finally.


What’s really happening

  • The DB error (ambiguous column) itself is not fatal, but somewhere after the query fails, a non-catchable error (e.g., using the failed result as an object) is thrown.
  • Because it isn’t caught by your try, and it’s not recoverable, PHP shuts down, skipping finally.
  • Your register_shutdown_function() gets invoked, because PHP runs shutdown handlers even on fatal errors.

How to confirm

Temporarily enable full error logging:

ini_set('display_errors', 1);
error_reporting(E_ALL);

Add a shutdown handler to log what kind of error triggered the shutdown:

register_shutdown_function(function () {
    $error = error_get_last();
    if ($error) {
        log_message('critical', 'Shutdown due to fatal: ' . print_r($error, true));
    }
});

If you see something like Fatal error: Uncaught Error: Call to a member function ... on null, that’s your answer.


Solution

  • Wrap more granular code in your try, including anything using the return of select():
try {
    $result = $PushSentLog->select(...);
    // Immediately validate it's the type you expect
} catch (\Throwable $e) {
    // Catch failures here
}
  • Use register_shutdown_function() defensively if cleanup like lock file removal is critical, especially in CLI or cron contexts.

Summary

  • finally is skipped because a non-catchable fatal error occurs
  • CI4 or PHP internals don’t “eat” the error, but your app likely has hidden errors after the DB failure
  • Use error_get_last() in a shutdown handler to identify the real cause

Let me know if you want help debugging that exact line or restructuring the select() usage more safely.