Home PHP PHP Exception Handling Explained — try, catch, finally and Custom Exceptions

PHP Exception Handling Explained — try, catch, finally and Custom Exceptions

In Plain English 🔥
Imagine you're a pilot going through a pre-flight checklist. If the fuel gauge is broken, you don't just ignore it and take off — you have a procedure: stop, report the problem, and decide what to do next. PHP exceptions work exactly the same way. When something goes wrong in your code, instead of silently crashing or spitting out gibberish to the user, you 'throw' the problem upward so something smarter can catch it and handle it gracefully. The 'try' block is your flight attempt, the 'catch' block is your emergency protocol, and 'finally' is the post-flight shutdown you always do, no matter what happened up there.
⚡ Quick Answer
Imagine you're a pilot going through a pre-flight checklist. If the fuel gauge is broken, you don't just ignore it and take off — you have a procedure: stop, report the problem, and decide what to do next. PHP exceptions work exactly the same way. When something goes wrong in your code, instead of silently crashing or spitting out gibberish to the user, you 'throw' the problem upward so something smarter can catch it and handle it gracefully. The 'try' block is your flight attempt, the 'catch' block is your emergency protocol, and 'finally' is the post-flight shutdown you always do, no matter what happened up there.

Most PHP developers spend their early days hoping their code just works. But in production — where real users, real databases, and real network failures live — 'hoping' isn't a strategy. A user submits a payment form and your database is temporarily unreachable. A file upload hits a disk quota limit. A third-party API times out. Without exception handling, every one of these scenarios either crashes your app visibly or, worse, fails silently and corrupts data. Exception handling is the engineering discipline that separates apps you'd trust with your credit card from ones you wouldn't.

Before exceptions existed in PHP (they were introduced properly in PHP 5), error handling was a patchwork of return codes, global error flags, and the dreaded die() call. You'd check if ($result === false) after every function call and hope you didn't miss one. The problem is that error-checking code and business logic got tangled together until neither was readable. Exceptions fixed this by separating 'what you want to do' from 'what happens when it goes wrong' — two very different concerns that deserve to live in different places.

By the end of this article you'll be able to write try/catch/finally blocks that actually mean something, build your own custom exception hierarchy for a real application, chain exceptions so you never lose diagnostic context, and avoid the three mistakes that make exception handling worse than useless.

How try, catch, and finally Actually Work Together

The try block wraps code that might fail. The moment an exception is thrown inside it — whether by your code or a library you're calling — PHP immediately stops executing that block and jumps to the matching catch. Nothing in try after the throw line runs. That's critical to understand: execution doesn't resume where it left off.

A catch block declares which exception type it handles. If the thrown exception matches (or is a subclass of) the declared type, the catch runs. You can stack multiple catch blocks to handle different failure types differently — more on that in a moment.

finally is the block that runs unconditionally — whether the try succeeded, whether an exception was caught, even if the catch block itself threw another exception. This makes it perfect for cleanup work: closing file handles, releasing database connections, or resetting state that must be tidied regardless of outcome.

Think of finally as the janitor who locks up the building every night, whether the workday went smoothly or ended in a fire drill.

DatabaseConnectionExample.php · PHP
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647
<?php

/**
 * Simulates reading a user record from a database.
 * This mirrors what happens in real apps when DB connections are involved.
 */
function fetchUserById(int $userId, bool $simulateFailure = false): array
{
    // Pretend we opened a database connection here
    echo "[DB] Opening connection...\n";

    try {
        if ($simulateFailure) {
            // This is what happens when the DB is unreachable or the query fails
            throw new RuntimeException(
                "Could not fetch user #$userId: connection timed out after 30s"
            );
        }

        // Happy path: query succeeds
        echo "[DB] Query executed successfully.\n";
        return ['id' => $userId, 'name' => 'Alice Nguyen', 'email' => 'alice@example.com'];

    } catch (RuntimeException $exception) {
        // We catch the specific exception type — not just any throwable
        // This lets other exception types bubble up if we don't handle them
        echo "[ERROR] Database error caught: " . $exception->getMessage() . "\n";

        // Return a safe default so the caller doesn't receive null unexpectedly
        return [];

    } finally {
        // This block ALWAYS runs — connection is always cleaned up
        // Even if catch() threw another exception, this still executes
        echo "[DB] Closing connection (finally block ran).\n";
    }
}

// --- Run 1: Normal operation ---
echo "=== Successful Fetch ===\n";
$user = fetchUserById(42, simulateFailure: false);
echo "User data: " . json_encode($user) . "\n\n";

// --- Run 2: Simulated failure ---
echo "=== Failed Fetch ===\n";
$user = fetchUserById(42, simulateFailure: true);
echo "User data: " . json_encode($user) . "\n";
▶ Output
=== Successful Fetch ===
[DB] Opening connection...
[DB] Query executed successfully.
[DB] Closing connection (finally block ran).
User data: {"id":42,"name":"Alice Nguyen","email":"alice@example.com"}

=== Failed Fetch ===
[DB] Opening connection...
[ERROR] Database error caught: Could not fetch user #42: connection timed out after 30s
[DB] Closing connection (finally block ran).
User data: []
⚠️
Watch Out: finally runs even when catch re-throwsIf your catch block throws a new exception (or re-throws the original), `finally` still runs before that new exception propagates. This is correct behaviour, not a bug — but it means any state changes you make inside `finally` are visible to whatever catches the re-thrown exception upstream. Never put logic in `finally` that depends on the try having succeeded.

Building a Custom Exception Hierarchy for Real Applications

PHP's built-in exceptions — RuntimeException, InvalidArgumentException, OverflowException — are useful, but they're generic. When you're building a payment processing module, catching a RuntimeException doesn't tell you whether it was a declined card, a network timeout, or a configuration error. Each of those failures needs a different response.

The solution is a custom exception hierarchy. You create a base exception class for your domain (e.g., PaymentException), then extend it into specific types. Callers can catch the base type to handle all payment errors uniformly, or catch a specific subtype to handle it precisely.

This mirrors how real-world software is built. Laravel does exactly this — it has HttpException as a base with NotFoundHttpException, AuthorizationException, and others branching from it. Symfony, Doctrine, and every serious PHP library follow the same pattern.

The other power move: custom exceptions can carry extra context. A plain RuntimeException holds a message and a code. Your PaymentDeclinedException can also hold the card's last four digits, the processor's response code, and a suggested retry strategy. That context is invaluable for logging and for deciding what to show the user.

PaymentExceptionHierarchy.php · PHP
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107
<?php

// ─────────────────────────────────────────────
// EXCEPTION HIERARCHY
// ─────────────────────────────────────────────

/**
 * Base class for ALL payment-related errors in this system.
 * Catching this type handles any payment failure — useful at the controller layer.
 */
class PaymentException extends RuntimeException {}

/**
 * Thrown when the card processor declines the charge.
 * Carries extra context beyond a plain message.
 */
class PaymentDeclinedException extends PaymentException
{
    public function __construct(
        private readonly string $declineReason,
        private readonly string $cardLastFour,
        int $processorCode,
        \Throwable $previous = null   // $previous links to the original low-level error
    ) {
        // Build a human-readable message for logs
        $message = "Card ending $cardLastFour declined: $declineReason (code: $processorCode)";
        parent::__construct($message, $processorCode, $previous);
    }

    public function getDeclineReason(): string { return $this->declineReason; }
    public function getCardLastFour(): string  { return $this->cardLastFour; }
}

/**
 * Thrown when we can't even reach the payment gateway — not a card problem.
 */
class PaymentGatewayUnavailableException extends PaymentException {}

// ─────────────────────────────────────────────
// SERVICE LAYER
// ─────────────────────────────────────────────

class PaymentService
{
    /**
     * Attempts to charge a card. Throws typed exceptions instead of returning
     * ambiguous booleans or null values that callers might forget to check.
     */
    public function chargeCard(string $cardLastFour, float $amountGbp): void
    {
        echo "[PaymentService] Attempting to charge £$amountGbp to card ending $cardLastFour\n";

        // Simulate: card ending 0000 always gets declined
        if ($cardLastFour === '0000') {
            throw new PaymentDeclinedException(
                declineReason: 'Insufficient funds',
                cardLastFour: $cardLastFour,
                processorCode: 1051
            );
        }

        // Simulate: card ending 9999 means gateway is down
        if ($cardLastFour === '9999') {
            throw new PaymentGatewayUnavailableException(
                "Stripe gateway returned HTTP 503 — try again in 30 seconds"
            );
        }

        echo "[PaymentService] Charge successful. Reference: TXN-" . strtoupper(bin2hex(random_bytes(4))) . "\n";
    }
}

// ─────────────────────────────────────────────
// CONTROLLER / CALLER LAYER
// ─────────────────────────────────────────────

function handleCheckout(string $cardLastFour, float $amount): void
{
    $paymentService = new PaymentService();

    try {
        $paymentService->chargeCard($cardLastFour, $amount);
        echo "[Checkout] Payment complete. Sending confirmation email.\n";

    } catch (PaymentDeclinedException $e) {
        // We know specifically WHY it failed — we can give useful feedback
        echo "[Checkout] Card declined: " . $e->getDeclineReason() . "\n";
        echo "[Checkout] Ask customer to try a different card ending in " . $e->getCardLastFour() . "\n";

    } catch (PaymentGatewayUnavailableException $e) {
        // Different failure, different response — queue a retry instead
        echo "[Checkout] Gateway down. Queuing payment for retry: " . $e->getMessage() . "\n";

    } catch (PaymentException $e) {
        // Fallback: catches any PaymentException subclass we didn't anticipate
        echo "[Checkout] Unexpected payment error: " . $e->getMessage() . "\n";
    }
}

echo "=== Test 1: Successful charge ===\n";
handleCheckout('4242', 79.99);

echo "\n=== Test 2: Declined card ===\n";
handleCheckout('0000', 79.99);

echo "\n=== Test 3: Gateway unavailable ===\n";
handleCheckout('9999', 79.99);
▶ Output
=== Test 1: Successful charge ===
[PaymentService] Attempting to charge £79.99 to card ending 4242
[PaymentService] Charge successful. Reference: TXN-3F8A1C2D
[Checkout] Payment complete. Sending confirmation email.

=== Test 2: Declined card ===
[PaymentService] Attempting to charge £79.99 to card ending 0000
[Checkout] Card declined: Insufficient funds
[Checkout] Ask customer to try a different card ending in 0000

=== Test 3: Gateway unavailable ===
[PaymentService] Attempting to charge £79.99 to card ending 9999
[Checkout] Gateway down. Queuing payment for retry: Stripe gateway returned HTTP 503 — try again in 30 seconds
⚠️
Pro Tip: Always pass $previous when wrapping exceptionsWhen you catch a low-level exception (e.g., a PDO database exception) and throw a domain-level one (e.g., `UserRepositoryException`), pass the original as the third constructor argument: `throw new UserRepositoryException('...', 0, $pdoException)`. This chains the exceptions so that your logging infrastructure can call `getPrevious()` and see the full stack trace of the root cause — not just your wrapper.

Exception Chaining and Global Handlers — Catching What You Missed

Even the best-written code has blind spots. An exception can bubble up through several function calls before being caught — or it can reach the top of the call stack without any catch at all. PHP gives you two safety nets for this: exception chaining (for preserving diagnostic context) and global exception/error handlers.

set_exception_handler() registers a callback that PHP calls for any uncaught exception. This is where you log the full stack trace, return a clean error page to the user, and alert your monitoring system — instead of PHP printing a raw exception message (or worse, a blank page in production).

For PHP 7+ errors that aren't traditional exceptions (like TypeError, DivisionByZeroError, ParseError), these are subclasses of Error, not Exception. Both Error and Exception implement the Throwable interface. Catching Throwable catches both — useful in global handlers, but be cautious about overusing it in normal code, as it can mask serious engine-level problems you'd rather know about immediately.

Finally, set_error_handler() converts old-style PHP warnings and notices into exceptions, which is essential for treating legacy PHP errors with the same seriousness as modern exceptions.

GlobalExceptionHandler.php · PHP
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586
<?php

/**
 * A minimal but production-realistic global exception handler setup.
 * In a real app, $logger would be a Monolog instance or similar PSR-3 logger.
 */

// ─────────────────────────────────────────────
// STEP 1: Convert legacy PHP errors into ErrorException
// This means a missing array key or a deprecated function call
// becomes a catchable exception rather than a silent warning.
// ─────────────────────────────────────────────
set_error_handler(function (int $severity, string $message, string $file, int $line): bool {
    // Only convert errors that match the current error_reporting level
    if (!(error_reporting() & $severity)) {
        return false; // Let PHP handle it normally
    }
    throw new ErrorException($message, 0, $severity, $file, $line);
});

// ─────────────────────────────────────────────
// STEP 2: Register a global handler for UNCAUGHT exceptions
// This is your last line of defence — anything not caught elsewhere lands here.
// ─────────────────────────────────────────────
set_exception_handler(function (Throwable $uncaughtException): void {
    // In production: log to file/service, show generic error page
    // In development: show full details
    $isDevelopment = (getenv('APP_ENV') === 'development');

    $logEntry = sprintf(
        "[UNCAUGHT] %s: %s in %s on line %d\nStack trace:\n%s",
        get_class($uncaughtException),
        $uncaughtException->getMessage(),
        $uncaughtException->getFile(),
        $uncaughtException->getLine(),
        $uncaughtException->getTraceAsString()
    );

    // Simulate logging (in real app: $logger->critical($logEntry))
    echo "[LOG] " . $logEntry . "\n";

    if ($isDevelopment) {
        echo "\n[DEV] Exception chained from: ";
        $previous = $uncaughtException->getPrevious();
        echo $previous ? $previous->getMessage() : "(none)";
        echo "\n";
    } else {
        echo "\n[USER] Something went wrong. Our team has been notified.\n";
    }

    // Always exit with a non-zero code so process managers know something failed
    exit(1);
});

// ─────────────────────────────────────────────
// STEP 3: Demonstrate exception chaining in action
// ─────────────────────────────────────────────

function loadConfigFile(string $filePath): array
{
    // In real code this would be: file_get_contents(), json_decode(), etc.
    // We simulate the low-level failure:
    throw new RuntimeException("File not found: $filePath");
}

function bootApplication(): void
{
    try {
        $config = loadConfigFile('/etc/myapp/config.json');
    } catch (RuntimeException $lowLevelError) {
        // Wrap the low-level error in a domain-meaningful exception.
        // Pass $lowLevelError as $previous so the full chain is preserved.
        throw new RuntimeException(
            "Application failed to boot: configuration could not be loaded",
            500,
            $lowLevelError  // <-- this is the chain link
        );
    }
}

// This exception is not caught anywhere — the global handler will catch it
puts_("--- Triggering uncaught exception via exception chain ---\n");

function puts_(string $text): void { echo $text; } // helper for clarity

bootApplication(); // throws → global handler fires
▶ Output
--- Triggering uncaught exception via exception chain ---
[LOG] [UNCAUGHT] RuntimeException: Application failed to boot: configuration could not be loaded in GlobalExceptionHandler.php on line 68
Stack trace:
#0 GlobalExceptionHandler.php(78): bootApplication()
#1 {main}

[USER] Something went wrong. Our team has been notified.
🔥
Interview Gold: Throwable vs ExceptionIn PHP 7+, `Error` and `Exception` both implement `Throwable` but are separate hierarchies. `TypeError`, `ArithmeticError`, and `ParseError` are all `Error` subclasses — they won't be caught by `catch (Exception $e)`. Use `catch (Throwable $e)` only in global handlers or framework bootstrapping code, not in normal business logic. Interviewers love this distinction.

Multiple catch Blocks, catch Union Types, and When to Re-throw

Catching the right exception at the right layer is the real skill. A database layer shouldn't know about HTTP status codes; a controller shouldn't know about SQL error codes. Each layer should catch what it can meaningfully handle and re-throw (or wrap and re-throw) everything else.

Re-throwing is done with a bare throw; inside a catch block — this preserves the original exception's stack trace, which is vital for debugging. If you throw $e; (with the variable), PHP resets the stack trace to the current line. Always use bare throw; when re-throwing.

PHP 8.0 introduced catch union types, letting you catch multiple unrelated exception types in a single block when you'd handle them identically. This keeps code DRY without forcing an artificial class hierarchy.

The golden rule: catch exceptions at the layer that has enough context to do something useful with them. If you can log it, recover from it, or convert it into something the next layer understands — catch it. If all you can do is re-throw it, let it bubble.

MultiCatchAndRethrow.php · PHP
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778
<?php

class UserNotFoundException extends RuntimeException {}
class UserSuspendedException extends RuntimeException {
    public function __construct(string $username, private readonly \DateTimeImmutable $suspendedUntil) {
        parent::__construct("User '$username' is suspended until " . $suspendedUntil->format('Y-m-d'));
    }
    public function getSuspendedUntil(): \DateTimeImmutable { return $this->suspendedUntil; }
}
class DatabaseUnavailableException extends RuntimeException {}
class InvalidCredentialsException extends RuntimeException {}

/**
 * Simulates an authentication service with multiple failure modes.
 */
function authenticateUser(string $username, string $password): string
{
    // Simulate different failure scenarios based on username
    return match($username) {
        'ghost'     => throw new UserNotFoundException("No account found for '$username'"),
        'banned'    => throw new UserSuspendedException('banned', new \DateTimeImmutable('+7 days')),
        'wrongpass' => throw new InvalidCredentialsException("Password incorrect for '$username'"),
        'dbdown'    => throw new DatabaseUnavailableException("Auth DB unreachable"),
        default     => "session_token_" . md5($username . time())
    };
}

/**
 * Handles login — catches what it can recover from, re-throws what it can't.
 */
function handleLoginRequest(string $username, string $password): void
{
    try {
        $sessionToken = authenticateUser($username, $password);
        echo "[Auth] Login successful. Token: $sessionToken\n";

    } catch (UserSuspendedException $e) {
        // We have specific context — we can give a targeted message
        $until = $e->getSuspendedUntil()->format('d M Y');
        echo "[Auth] Account suspended until $until. Contact support@example.com\n";

    } catch (UserNotFoundException | InvalidCredentialsException $e) {
        // PHP 8 union catch: both failures get the same vague response
        // (intentionally vague — never tell attackers which one failed)
        echo "[Auth] Invalid username or password.\n";

    } catch (DatabaseUnavailableException $e) {
        // This layer can't fix a DB outage — re-throw so the framework
        // can log it and show a 503 page
        echo "[Auth] DB unavailable — re-throwing for framework to handle\n";
        throw; // bare throw — preserves original stack trace!
    }
}

$testCases = [
    ['alice',     'correct_password'],
    ['ghost',     'any_password'],
    ['banned',    'any_password'],
    ['wrongpass', 'wrong_password'],
];

foreach ($testCases as [$username, $password]) {
    echo "\n--- Login attempt: $username ---\n";
    try {
        handleLoginRequest($username, $password);
    } catch (\Throwable $e) {
        // Top-level catch for anything re-thrown (like DatabaseUnavailableException)
        echo "[FRAMEWORK] Unhandled error logged and 503 returned: " . $e->getMessage() . "\n";
    }
}

// Test the DB-down case separately
echo "\n--- Login attempt: dbdown ---\n";
try {
    handleLoginRequest('dbdown', 'any');
} catch (\Throwable $e) {
    echo "[FRAMEWORK] Unhandled error logged and 503 returned: " . $e->getMessage() . "\n";
}
▶ Output
--- Login attempt: alice ---
[Auth] Login successful. Token: session_token_3f8a1d2c4b7e9a5f

--- Login attempt: ghost ---
[Auth] Invalid username or password.

--- Login attempt: banned ---
[Auth] Account suspended until 15 Jul 2025. Contact support@example.com

--- Login attempt: wrongpass ---
[Auth] Invalid username or password.

--- Login attempt: dbdown ---
[Auth] DB unavailable — re-throwing for framework to handle
[FRAMEWORK] Unhandled error logged and 503 returned: Auth DB unreachable
⚠️
Pro Tip: throw as an expression (PHP 8+)PHP 8.0 made `throw` an expression, so you can use it in arrow functions, ternaries, and match arms: `$value = $input ?? throw new InvalidArgumentException('Input required');`. This is especially clean for guard clauses at the top of functions — replaces the old `if (!$input) { throw ... }` pattern with a single, readable line.
AspectException (extends Exception)Error (extends Error)
Hierarchy rootException implements ThrowableError implements Throwable
Typical sourceApplication/library logicPHP engine / type system
Common examplesRuntimeException, InvalidArgumentExceptionTypeError, DivisionByZeroError, ParseError
Caught by catch(Exception $e)YesNo — will bubble past
Caught by catch(Throwable $e)YesYes
When to catch in business codeFrequently — expected failuresRarely — usually indicates a bug
IntroducedPHP 5PHP 7 (Error hierarchy restructured)

🎯 Key Takeaways

  • The finally block runs unconditionally — even if catch re-throws. Use it for cleanup (closing connections, releasing locks) that must happen regardless of success or failure.
  • Build a custom exception hierarchy for every domain module. A PaymentException base with specific subclasses means callers can handle all payment errors uniformly or surgically handle specific failure types.
  • Always pass the original exception as $previous when wrapping it in a new exception — throw new DomainException('...', 0, $originalException). This preserves the full chain for debugging without leaking low-level details to callers.
  • In PHP 7+, Error and Exception are separate hierarchies both implementing Throwable. catch (Exception $e) won't catch a TypeError. Use catch (Throwable $e) only in global handlers — not scattered through business logic.

⚠ Common Mistakes to Avoid

  • Mistake 1: Catching \Exception (or \Throwable) everywhere as a lazy catch-all — Symptom: real bugs like TypeErrors and programming mistakes get silently swallowed, your logs show nothing, and the app returns incorrect data instead of failing loudly. Fix: catch the most specific exception type you can. Only catch \Throwable in global handlers or framework bootstrapping, and always log the full stack trace there.
  • Mistake 2: Using throw $e; instead of bare throw; when re-throwing — Symptom: the stack trace in your logs shows the re-throw line as the origin of the exception, making it look like the error started somewhere other than where it actually did. You waste 20 minutes debugging the wrong file. Fix: always use bare throw; inside a catch block to re-throw the current exception with its original stack trace intact.
  • Mistake 3: Swallowing exceptions inside finally blocks — Symptom: you have cleanup code inside finally that itself throws an exception; this silently discards the original exception that triggered the finally block in the first place, making root-cause analysis nearly impossible. Fix: wrap cleanup code inside finally in its own try/catch, log any cleanup failures separately, and never let an exception escape from finally unless you explicitly intend to replace the original.

Interview Questions on This Topic

  • QWhat is the difference between Error and Exception in PHP 7+, and when would you catch Throwable instead of Exception?
  • QIf you catch an exception and want to re-throw it, what is the difference between `throw;` and `throw $e;`, and why does it matter?
  • QA junior developer puts a `return` statement inside a `finally` block. What happens to an exception that was being propagated through that method, and why is this considered dangerous?

Frequently Asked Questions

What is the difference between PHP exceptions and PHP errors?

In PHP 7+, traditional PHP errors (notices, warnings, fatals) are separate from exceptions. PHP 7 introduced the Error class hierarchy — TypeError, ParseError, DivisionByZeroError etc. — which are throwable like exceptions but don't extend Exception. Both implement Throwable. A catch (Exception $e) block won't catch an Error; you need catch (Throwable $e) or a specific catch (Error $e) to handle engine-level errors.

Should I always catch exceptions or let them bubble up?

Catch an exception only at the layer that has enough context to do something useful with it — log it, recover from it, or convert it into a more meaningful type. If you'd catch it just to re-throw it unchanged, let it bubble naturally. Catching too eagerly hides bugs; catching too late means unhelpful error messages for users. A global exception handler (set_exception_handler) should always be your final safety net.

Can I use exceptions for normal control flow in PHP?

You technically can, but you shouldn't. Exceptions carry significant overhead because PHP captures a full stack trace when they're created. More importantly, using exceptions for expected conditional logic (like 'no results found') violates the principle of least surprise — exceptions should signal genuinely exceptional, unexpected conditions. For expected outcomes like empty results, return a null, empty array, or a dedicated result object instead.

🔥
TheCodeForge Editorial Team Verified Author

Written and reviewed by senior developers with real-world experience across enterprise, startup and open-source projects. Every article on TheCodeForge is written to be clear, accurate and genuinely useful — not just SEO filler.

← PreviousComposer and Autoloading in PHPNext →PHP Design Patterns
Forged with 🔥 at TheCodeForge.io — Where Developers Are Forged