Designing for Reusability and Error Handling

14 min read

Designing Reusable PHP Functions with Robust Error Handling

Reliable software is not defined only by what happens when everything goes according to plan. In production, databases become temporarily unavailable, queries fail, data violates constraints, configuration is incomplete, and unexpected inputs reach application code. A professional PHP application must therefore be designed not only to perform successful operations, but also to fail in a controlled, understandable, and recoverable way.

This is where two engineering principles become especially important: reusability and centralized error handling.

A database helper should not force every part of an application to understand PDO internals. Likewise, every controller or page should not independently decide how database exceptions are displayed, logged, or translated into application responses.

The goal is a clear boundary: a reusable function performs one well-defined operation, while an appropriate higher layer decides what the failure means to the user or business workflow.

1. The Real Problem: Database Code Spreads Quickly

Consider an application that stores configuration values in an options table. A developer might initially write:

$stmt = $pdo->prepare("
    UPDATE options
    SET value = :value
    WHERE name = :name
");

$stmt->execute([
    ':name' => $name,
    ':value' => $value
]);

That works for one location. But as the application grows, the same logic may appear in several controllers, administrative pages, API endpoints, command-line scripts, and background jobs.

Eventually, slightly different versions appear:

try {
    // SQL operation
} catch (PDOException $e) {
    echo $e->getMessage();
}

Elsewhere:

try {
    // Similar SQL operation
} catch (PDOException $e) {
    return false;
}

And somewhere else:

try {
    // Similar SQL operation
} catch (PDOException $e) {
    error_log($e->getMessage());
}

The application now has three different interpretations of database failure.

This is a maintainability problem.

Reusable database functions help establish a consistent technical contract.

2. What Reusability Actually Means

Reusability does not mean making every function extremely generic. It means creating a component with a clear responsibility that can safely be called from multiple places.

For example:

function update_option(PDO $pdo, string $name, string $value): bool
{
    $stmt = $pdo->prepare("
        UPDATE `options`
        SET `value` = :value
        WHERE `name` = :name
    ");

    return $stmt->execute([
        ':name' => $name,
        ':value' => $value
    ]);
}

The function has one clear responsibility:

Execute an option update using the supplied PDO connection.

It does not render HTML. It does not redirect the browser. It does not decide what message an administrator should see. It does not contain business-specific UI behavior.

That narrow responsibility makes it easier to reuse.

3. Explicit Dependencies Make Functions Stronger

A common shortcut is using a global PDO variable:

function update_option($name, $value)
{
    global $pdo;

    // database operation
}

Although this can work, it hides the function's dependency.

A better design makes the dependency explicit:

function update_option(PDO $pdo, string $name, string $value): bool
{
    // database operation
}

Now anyone reading the function immediately understands what it needs.

This also makes testing easier because a test can provide its own PDO connection.

Explicit dependencies are particularly valuable in larger applications because they reduce hidden coupling. A function that silently depends on global state is more difficult to move, test, or reuse.

4. Understanding PDO Exceptions

PDO can report database errors through exceptions when configured appropriately:

$pdo->setAttribute(
    PDO::ATTR_ERRMODE,
    PDO::ERRMODE_EXCEPTION
);

With exception mode enabled, a database failure can produce a PDOException.

For example:

try {
    $stmt = $pdo->prepare("
        UPDATE options
        SET value = :value
        WHERE name = :name
    ");

    $stmt->execute([
        ':name' => $name,
        ':value' => $value
    ]);
} catch (PDOException $e) {
    // Handle or propagate the failure.
}

This creates a structured failure path rather than requiring every SQL method call to manually inspect an error code.

5. The Danger of Displaying Raw Database Errors

One of the most common beginner mistakes is:

catch (PDOException $e) {
    echo $e->getMessage();
}

This may be useful during local development, but it is generally inappropriate for a production-facing application.

A database exception may contain information about:

  • Table names.
  • Column names.
  • SQL statements.
  • Database configuration.
  • Constraint names.
  • Internal implementation details.

Showing that information to an end user can expose details that should remain inside the application's operational logs.

A stronger approach is to log the technical error securely and expose an appropriate application-level response.

6. Returning a Boolean: A Simple Contract

For simple helper functions, returning a boolean can create a clean contract:

return $stmt->execute([
    ':name' => $name,
    ':value' => $value
]);

The caller can then write:

if (update_option($pdo, 'site_title', 'Example')) {
    // Continue the workflow.
}

Or:

if (!update_option($pdo, 'site_title', 'Example')) {
    // Handle unsuccessful execution.
}

The calling layer does not need to know the details of prepared statements.

However, developers should understand the meaning of the boolean. It generally represents whether the database statement executed successfully, not necessarily whether the intended business state was achieved.

7. Execution Success Is Not the Same as Business Success

Consider this query:

UPDATE options
SET value = :value
WHERE name = :name

Suppose name does not exist.

The SQL statement itself can execute successfully while modifying zero records.

Therefore, these concepts should be distinguished:

  • SQL execution success: the database accepted and executed the statement.
  • Affected rows: how many rows the database reports as affected.
  • Business success: whether the application achieved the state it intended.

Depending on the requirement, the function may need to inspect rowCount(), perform an existence check, or use an upsert instead.

There is no universal return type that is correct for every database operation. The function's contract should reflect what the caller actually needs to know.

8. When to Catch an Exception

One of the most important architectural decisions is deciding where an exception should be caught.

A common mistake is catching every exception immediately inside the lowest-level helper:

function update_option(PDO $pdo, string $name, string $value): bool
{
    try {
        // SQL operation
    } catch (PDOException $e) {
        return false;
    }
}

This can be convenient, but it also discards valuable diagnostic information.

The database layer knows that something went wrong. It may not know what the appropriate business response should be.

For example, a controller might need to:

  • Show a user-friendly message.
  • Log the failure.
  • Return an HTTP error response.
  • Retry the operation.
  • Abort a transaction.
  • Notify an operational monitoring system.

The database helper should not necessarily make those decisions.

9. Centralized Error Handling

A cleaner architecture allows lower-level code to throw an exception while a higher-level boundary handles it.

For example:

function update_option(PDO $pdo, string $name, string $value): bool
{
    $stmt = $pdo->prepare("
        UPDATE options
        SET value = :value
        WHERE name = :name
    ");

    return $stmt->execute([
        ':name' => $name,
        ':value' => $value
    ]);
}

Then an application boundary can handle the exception:

try {
    update_option($pdo, 'site_title', 'Example');
} catch (PDOException $e) {
    error_log($e->getMessage());

    // Return an appropriate application response.
}

This keeps the responsibilities separated.

The helper performs the operation.

The application boundary decides how to communicate the failure.

10. Designing a Consistent CRUD Layer

The same principle applies to the rest of CRUD operations.

Create

function create_option(PDO $pdo, string $name, string $value): bool
{
    $stmt = $pdo->prepare("
        INSERT INTO options (name, value)
        VALUES (:name, :value)
    ");

    return $stmt->execute([
        ':name' => $name,
        ':value' => $value
    ]);
}

Read

function get_option(PDO $pdo, string $name): ?string
{
    $stmt = $pdo->prepare("
        SELECT value
        FROM options
        WHERE name = :name
        LIMIT 1
    ");

    $stmt->execute([
        ':name' => $name
    ]);

    $value = $stmt->fetchColumn();

    return $value === false ? null : $value;
}

Update

function update_option(PDO $pdo, string $name, string $value): bool
{
    $stmt = $pdo->prepare("
        UPDATE options
        SET value = :value
        WHERE name = :name
    ");

    return $stmt->execute([
        ':name' => $name,
        ':value' => $value
    ]);
}

Delete

function delete_option(PDO $pdo, string $name): bool
{
    $stmt = $pdo->prepare("
        DELETE FROM options
        WHERE name = :name
    ");

    return $stmt->execute([
        ':name' => $name
    ]);
}

These functions establish a predictable interface around database operations.

11. Validation Belongs in the Appropriate Layer

Error handling and validation are related but should not automatically be mixed together.

Suppose an option name cannot be empty.

The application might validate:

if ($name === '') {
    throw new InvalidArgumentException(
        'Option name cannot be empty.'
    );
}

That is different from a database failure.

Similarly, a maximum length restriction may be validated before the database operation:

if (mb_strlen($name) > 255) {
    throw new InvalidArgumentException(
        'Option name is too long.'
    );
}

Meanwhile, the database remains responsible for enforcing its own structural constraints.

This gives the application multiple layers of protection rather than forcing one function to perform every possible responsibility.

12. Logging: Capture the Right Information

When a database exception occurs, developers need enough information to investigate it.

A basic development-oriented example is:

catch (PDOException $e) {
    error_log($e->getMessage());
    throw $e;
}

In a larger production system, logging should be structured and should avoid exposing secrets or sensitive values.

Do not blindly log credentials, tokens, authorization headers, or sensitive user data just because an exception occurred.

The objective of logging is not "log everything." The objective is to preserve the information required to diagnose and resolve the failure safely.

13. Reusability as a Career Skill

Reusable code is a practical engineering skill that can be demonstrated clearly during technical interviews and portfolio reviews.

Instead of saying:

"I know PDO."

A stronger technical demonstration shows:

  • A reusable database abstraction.
  • Prepared statements.
  • Explicit dependencies.
  • Type declarations.
  • Consistent return contracts.
  • Exception-based error handling.
  • Separation between database and presentation layers.
  • Tests covering both successful and failing scenarios.

These are concrete engineering behaviors that another developer can inspect and evaluate.

14. A Practical Portfolio Exercise

Build a small configuration service using PHP and PDO.

Start with:

options
--------
id
name
value

Then implement:

get_option()
create_option()
update_option()
delete_option()

Configure PDO to use exception mode.

Then create a service or controller that calls these functions.

Test scenarios such as:

  • Creating a valid option.
  • Reading an existing option.
  • Updating an existing option.
  • Deleting an option.
  • Attempting to access a missing option.
  • Supplying invalid input.
  • Triggering a database constraint violation.
  • Simulating a database connection failure.

Document what happens at each layer.

Input
  ↓
Validation
  ↓
Service
  ↓
PDO Repository / Helper
  ↓
MySQL
  ↓
Exception / Result
  ↓
Application Response

This demonstrates architecture, not merely syntax.

15. Avoiding Overengineering

There is an important balance between reusable code and excessive abstraction.

A simple PHP application does not necessarily need a complicated repository framework, service container, event bus, custom exception hierarchy, and multiple interfaces just to update one configuration value.

Start with a clear function:

update_option($pdo, $name, $value);

Introduce additional abstraction when the application demonstrates a real need for it.

A good engineer does not maximize abstraction. A good engineer minimizes unnecessary complexity while preserving clear responsibilities.

16. Designing for Failure, Not Just Success

A mature implementation considers failure paths before writing the final code.

Ask:

  • What happens if the database connection fails?
  • What happens if the SQL statement is invalid?
  • What happens if a constraint is violated?
  • What happens if the requested record does not exist?
  • What happens if the value is too long?
  • What happens if two requests modify the same record?
  • What information should be logged?
  • What should the end user see?

These questions are not theoretical. They determine whether an application remains manageable when something inevitably goes wrong.

Senior Developer Insight

The strongest lesson is that error handling is an architectural responsibility, not simply a try-catch statement.

Writing this:

try {
    // SQL
} catch (PDOException $e) {
    // Something
}

is easy.

Designing the correct location for that try...catch is the real engineering problem.

A senior developer thinks in layers.

The database layer understands database operations.

The service layer understands application rules.

The controller or API layer understands how to communicate with the client.

The logging and monitoring layer understands how failures should be recorded for operational diagnosis.

When these responsibilities are mixed together, small changes become expensive. A database helper that prints HTML cannot easily be reused by an API. A function that redirects the browser cannot safely be called from a command-line script. A helper that silently converts every exception into false can make serious database failures indistinguishable from ordinary business conditions.

Good boundaries preserve agency at every layer: each component knows what it is responsible for and, equally importantly, what it is not responsible for.

A strong baseline is therefore simple:

function update_option(PDO $pdo, string $name, string $value): bool
{
    $stmt = $pdo->prepare("
        UPDATE options
        SET value = :value
        WHERE name = :name
    ");

    return $stmt->execute([
        ':name' => $name,
        ':value' => $value
    ]);
}

Let the database operation report its failure through the established exception mechanism. Let the appropriate higher-level boundary decide whether to log, retry, translate, or present the failure.

This is the difference between code that merely works and code that remains understandable when the system becomes larger.

Conclusion

Reusable PHP database functions provide a foundation for maintainable application architecture. By passing dependencies explicitly, using prepared statements, defining clear return contracts, and separating database operations from user-facing behavior, developers can create components that remain useful as an application grows.

Robust error handling completes that design. Configure PDO to report failures appropriately, avoid exposing raw database exceptions to users, preserve useful diagnostic information through secure logging, and catch exceptions at the layer that has enough context to make the correct decision.

The practical development workflow is straightforward:

Define responsibility
        ↓
Make dependencies explicit
        ↓
Use prepared statements
        ↓
Define success/failure semantics
        ↓
Allow meaningful exceptions to propagate
        ↓
Handle them at the correct application boundary
        ↓
Log safely
        ↓
Test both success and failure paths

These practices scale from a small PHP script to a larger production application. More importantly, they demonstrate a transferable engineering skill: designing software around clear responsibilities, predictable contracts, and controlled failure rather than assuming that every operation will succeed.

:::

Free consultation — Response within 24h

Let's build
something great

500+ projects delivered. 8+ years of expertise. Enterprise systems, AI, and high-performance applications.