PHP 8.5 in Development: Fatal Error Stack Traces & Error and Exception Handler Getters

12813 views
PHP 8.5 in Development Fatal Error Stack Traces & Error and Exception Handler Getters

PHP 8.5 is shaping up to be a developer-experience–focused release, and two important improvements stand out for debugging and observability:

  1. Fatal Error Stack Traces
  2. Error & Exception Handler Getters

These changes aim to reduce debugging time, improve error visibility, and give frameworks and tools more control over PHP’s internal error-handling mechanisms.

In this article, we´ll explore what problems existed before, what PHP 8.5 improves, and practical examples showing how developers can benefit from these features.

 

1. Fatal Error Stack Traces

The Problem Before PHP 8.5

In PHP versions up to 8.4, fatal errors (such as calling an undefined function, accessing a property on null, or running out of memory) would:

  • Terminate script execution immediately
  • Display a short error message
  • Not provide a full stack trace in many fatal scenarios

This made debugging production issues difficult, especially when:

  • Errors occurred deep inside framework code
  • The failure was triggered indirectly
  • Logs lacked sufficient context

Example (Before PHP 8.5)

 function levelOne() {
levelTwo();
}


function levelTwo() {
levelThree();
}


function levelThree() {
undefinedFunction();
}


levelOne(); 

Typical output (simplified):

 Fatal error: Uncaught Error: Call to undefined function undefinedFunction() 

Missing information: Which function called it? From where? In what order?

 

What’s New in PHP 8.5

PHP 8.5 introduces automatic stack traces for fatal errors, aligning fatal error output more closely with uncaught exceptions.

Now, when a fatal error occurs, PHP can provide:

  • Full call stack
  • File names and line numbers
  • Clear execution flow

This makes fatal errors far easier to diagnose, especially in logs.

Example in PHP 8.5

Using the same code as before:

 levelOne(); 

Expected output (conceptual):

 Fatal error: Uncaught Error: Call to undefined function undefinedFunction()
Stack trace:
#0 /app/example.php(8): levelThree()
#1 /app/example.php(4): levelTwo()
#2 /app/example.php(12): levelOne() 

You immediately see:

  • The execution path
  • The exact failure point
  • The order of function calls

 

Why This Matters

  • Faster debugging in production logs
  • Improved error monitoring (Sentry, New Relic, etc.)
  • Better framework diagnostics
  • Reduced guesswork for developers

 

2. Error & Exception Handler Getters

The Problem Before PHP 8.5

PHP allows developers to define custom handlers using:

 set_error_handler();
set_exception_handler(); 

However, there was no official way to retrieve the currently registered handlers.

This caused issues for:

  • Frameworks
  • Middleware
  • Debug tools
  • Libraries that temporarily override handlers

Common Workaround (Pre-8.5)

Developers often stored handlers manually:

 $previousHandler = set_exception_handler($myHandler); 

This approach:

  • Was error-prone
  • Didn’t work well across multiple layers
  • Made handler chaining difficult

 

What’s New in PHP 8.5

PHP 8.5 introduces getter functions for error and exception handlers.

These allow you to inspect the current handler without modifying it.

Conceptually, PHP 8.5 provides:

 get_error_handler();
get_exception_handler(); 

(Exact function names may evolve before final release.)

Basic Example

set_exception_handler(function (Throwable $e) {
echo "Custom Exception Handler: ".$e->getMessage();
});


$currentHandler = get_exception_handler();


var_dump($currentHandler);

You can now:

  • Inspect existing handlers
  • Decide whether to replace or wrap them
  • Restore handlers safely

Advanced Example: Handler Wrapping

$previousHandler = get_exception_handler();


set_exception_handler(function (Throwable $e) use ($previousHandler) {
// Custom logging
error_log($e);


// Delegate to previous handler
if ($previousHandler) {
$previousHandler($e);
}
});

This pattern is extremely useful for:

  • Logging libraries
  • Debug toolbars
  • Framework bootstrapping

Error Handler Example

set_error_handler(function ($severity, $message, $file, $line) {
echo "Error [$severity]: $message in $file on line $line";
});


$currentErrorHandler = get_error_handler();

This allows safe inspection and chaining without breaking existing logic.

 

Real-World Use Cases

1. Frameworks

  • Safely integrate third-party libraries
  • Avoid overwriting application-level handlers
  • Improve error visibility

2. Monitoring & Logging Tools

  • Attach logging without hijacking handlers
  • Restore original behavior cleanly

3. Debugging & Dev Tools

  • Provide richer error pages
  • Detect active handlers dynamically

4. Enterprise Applications

  • Better production diagnostics
  • Reduced MTTR (Mean Time to Resolution)

 

PHP 8.5 vs PHP 8.4 (Summary)

Feature PHP 8.4 PHP 8.5 (In Development)
Fatal error stack traces ❌ Limited ✅ Detailed
Inspect error handlers ❌ No ✅ Yes
Inspect exception handlers ❌ No ✅ Yes
Debugging experience ⚠️ Moderate 🚀 Improved

 

PHP 8.5 continues PHP’s evolution toward modern, developer-friendly error handling.

  • Fatal Error Stack Traces remove blind spots in debugging
  • Error & Exception Handler Getters empower frameworks and tools

Together, these features significantly improve observability, reliability, and maintainability of PHP applications.

If you work with large PHP codebases, frameworks, or production systems, PHP 8.5’s error-handling improvements alone are worth the upgrade.

Stay tuned for more PHP 8.5 feature deep dives!

 

Why Fatal Errors Didn’t Have Stack Traces Before PHP 8.5

why it was this way for so long — and understanding the why makes the change more meaningful.

PHP’s error handling is split between two distinct systems that have always worked differently:

Exceptions are first-class language constructs managed entirely within the Zend Engine’s virtual machine. When an exception is thrown, PHP has the full call stack in memory because the VM maintains a frame stack as part of normal execution. debug_backtrace() has always worked for exceptions because the frame data is right there.

Fatal errors (E_ERROR, E_PARSE, E_CORE_ERROR, E_COMPILE_ERROR) are a fundamentally different mechanism. Some of them — particularly memory exhaustion (E_ERROR: Allowed memory size exhausted) and stack overflows — occur in conditions where the PHP engine’s own state is compromised. Historically, attempting to collect and format a full backtrace during a fatal error risked crashing the engine itself, or recursing into the fatal condition that triggered the error in the first place. The conservative choice — taken early in PHP’s history and never revisited — was to emit only the immediate error location with no call stack.

PHP 8.5 revisits that decision with a more careful implementation: the backtrace is collected at the point of fatal error before the engine state degrades, and formatted using a safe path that avoids the recursion risk. The result is stack traces for the most common fatal error category — call errors, type errors, and access violations — though stack-overflow and OOM fatals still have limitations.

 

Part 1: Fatal Error Stack Traces in PHP 8.5

The Actual Output Format

Here is the verified format from PHP 8.5:

Fatal error: Uncaught Error: Call to undefined function undefinedFunction() in /app/example.php:9
Stack trace:
#0 /app/example.php(5): levelThree()
#1 /app/example.php(1): levelTwo()
#2 /app/example.php(13): levelOne()
#3 {main}
thrown in /app/example.php on line 9

This matches the format that Throwable::getTraceAsString() has always produced — which is deliberate. Fatal errors now output in the same format as uncaught exceptions, so log parsers, error monitoring services, and existing tooling that already parse exception traces work on fatal error output without modification.

The {main} frame at the end of the trace represents the top-level script execution context — the same marker you’ll see in exception stack traces from top-level code.

The Four Categories of Fatal Error — and Which Now Have Traces

Not all fatal errors behave the same way in PHP 8.5. Understanding the taxonomy matters for knowing what you’ll get in your logs:

E_ERROR             — Runtime fatal: call to undefined function, property access on
                      non-object, class not found, abstract method called directly.
                      ✅ Full stack trace in PHP 8.5.

E_PARSE             — Parse error: syntax error caught before execution begins.
                      ❌ No stack trace possible — execution never started.

E_CORE_ERROR        — PHP engine startup failure.
                      ❌ No stack trace — engine isn't fully initialized.

E_COMPILE_ERROR     — Opcode compilation failure.
                      ❌ No stack trace — script never ran.

For everyday production bugs, E_ERROR is by far the most common fatal category, and that’s exactly the one PHP 8.5 now traces fully.

Before and After: A Real Comparison

Consider a common real-world pattern — deep call stack in framework code, fatal error buried at the bottom:

<?php
// Before PHP 8.5: you'd see only the immediate error location.
// Debugging meant manually adding logging to find out how you got there.

class UserService {
    public function getProfile(int $userId): array {
        $raw = $this->repository->find($userId); // Returns null on missing ID
        return $this->formatProfile($raw);        // Fatal: method call on null
    }

    private function formatProfile($user): array {
        return $user->toArray(); // <- Fatal happens HERE
    }
}

class ProfileController {
    public function __construct(private UserService $service) {}

    public function show(int $id): void {
        $profile = $this->service->getProfile($id);
        echo json_encode($profile);
    }
}

$controller = new ProfileController(new UserService());
$controller->show(999); // Non-existent user

PHP 8.4 and below:

Fatal error: Call to a member function toArray() on null in /app/UserService.php on line 9

You can see the line. You cannot see who called show(), what $id was, or where in the controller stack this originated. In a framework with ten middleware layers, that single line is nearly useless.

PHP 8.5:

Fatal error: Uncaught Error: Call to a member function toArray() on null in /app/UserService.php:9
Stack trace:
#0 /app/UserService.php(5): UserService->formatProfile(NULL)
#1 /app/ProfileController.php(8): UserService->getProfile(999)
#2 /app/index.php(12): ProfileController->show(999)
#3 {main}
thrown in /app/UserService.php on line 9

Now you can see: getProfile(999) was called with ID 999, repository->find(999) returned null (because the user doesn’t exist), and formatProfile(null) triggered the fatal. The entire bug is readable from the trace without adding a single log statement.

Interaction with register_shutdown_function()

Before PHP 8.5, a common workaround for the missing fatal-error stack trace was using register_shutdown_function() combined with error_get_last():

// The pre-8.5 workaround — now largely redundant
register_shutdown_function(function () {
    $error = error_get_last();
    if ($error !== null && in_array($error['type'], [E_ERROR, E_PARSE, E_CORE_ERROR])) {
        // Log the error manually — no stack trace available
        error_log("FATAL [{$error['type']}]: {$error['message']} in {$error['file']}:{$error['line']}");
    }
});

In PHP 8.5, error_get_last() still works in shutdown functions but the stack trace is now part of the error message itself — so if you’re logging $error[‘message’], you automatically get the trace. The shutdown function pattern remains useful for other purposes (cleanup, metrics recording, alerting) but is no longer the primary mechanism for capturing fatal error context.

What About Memory Exhaustion?

The one genuinely tricky case: Allowed memory size exhausted fatals.

PHP 8.5 does its best here, but the situation is inherently difficult — when you’ve run out of memory, allocating memory to format a backtrace is problematic. PHP 8.5 pre-allocates a small emergency buffer specifically for this case, which usually allows a partial trace, but very deep call stacks at OOM boundaries may still produce truncated output.

The practical advice hasn’t changed: set memory_limit generously for development, use tools like Blackfire or Xdebug memory profiling to catch leaks before they reach production, and treat OOM traces as “better than nothing” rather than a complete picture.

 

Part 2: get_error_handler() and get_exception_handler()

These are the confirmed, shipped signatures in PHP 8.5:

get_error_handler(): ?callable
get_exception_handler(): ?callable

Both return the currently registered handler as a callable, or null if no custom handler is registered (i.e., PHP’s built-in handler is active).

Why the Workaround Was Brittle

The standard pre-8.5 pattern for saving and restoring a handler looked like this:

// Pre-8.5 workaround
$previous = set_exception_handler($myHandler);
// ... do some work ...
set_exception_handler($previous); // Restore

The problem: set_exception_handler() returns the previously registered handler, which means you had to replace the handler just to read it. This created three real problems:

1. There’s a window where your new handler is active — even if you were just trying to inspect the existing one, you briefly replaced it. In concurrent environments or during framework bootstrapping where order matters, this is a genuine race condition.

2. Multiple library authors doing the same thing create handler stacks that unwind incorrectly. Library A saves and replaces, Library B saves and replaces, Library A restores — now Library B’s handler is gone because A’s “previous” was set before B replaced it.

3. Reading a handler for inspection and reading it to replace it were the same operation — there was no way to distinguish intent.

get_error_handler() and get_exception_handler() are pure reads. They don’t touch the registered handler. The observation doesn’t change the state.

The Confirmed API

<?php
// PHP 8.5 — inspection without modification

// No custom handler registered yet
var_dump(get_exception_handler()); // NULL
var_dump(get_error_handler());     // NULL

// Register a handler
set_exception_handler(function (Throwable $e) {
    error_log('[EXCEPTION] ' . $e->getMessage());
});

// Inspect it — handler is unchanged
$handler = get_exception_handler();
var_dump(is_callable($handler)); // bool(true)

// The handler is still active — we only read it
throw new \RuntimeException('Test');
// Triggers: [EXCEPTION] Test

Pattern 1: Safe Handler Wrapping in Libraries and Middleware

This is the primary use case. A library or middleware package wants to add its own behavior (logging, Slack alerts, metrics) without replacing the application’s handler:

<?php
// Correct pattern in PHP 8.5 — read first, wrap safely

class ExceptionMiddleware {
    private ?callable $previous = null;

    public function register(): void {
        // Read the current handler BEFORE replacing it
        $this->previous = get_exception_handler();

        set_exception_handler(function (Throwable $e) {
            // Our added behavior
            $this->logToSentry($e);
            $this->incrementErrorCounter();

            // Delegate to whatever was registered before us
            if ($this->previous !== null) {
                ($this->previous)($e);
            } else {
                // No previous handler — PHP's default behavior is to display the error
                // Replicate that for dev, or suppress for production
                if ($_ENV['APP_ENV'] === 'development') {
                    throw $e; // Re-throw to get PHP's default output
                }
            }
        });
    }

    private function logToSentry(Throwable $e): void {
        // \Sentry\captureException($e);
    }

    private function incrementErrorCounter(): void {
        // Metrics::increment('errors.exceptions');
    }
}

$middleware = new ExceptionMiddleware();
$middleware->register();

Without get_exception_handler(), you couldn’t check whether there was an existing handler before wrapping — you’d have to set_exception_handler() speculatively and capture the return value, which replaces the handler even when you’re just checking.

Pattern 2: Plugin/Extension Safety Checks

WordPress plugins, Laravel packages, and other extension systems often need to verify they’re not conflicting with other registered handlers:

<?php
function register_my_plugin_handler(): void {
    $currentExceptionHandler = get_exception_handler();
    $currentErrorHandler     = get_error_handler();

    // Check if another plugin or framework already owns the handlers
    if ($currentExceptionHandler !== null) {
        $reflector = new ReflectionFunction(
            $currentExceptionHandler instanceof Closure
                ? $currentExceptionHandler
                : Closure::fromCallable($currentExceptionHandler)
        );
        $definedIn = $reflector->getFileName();

        // If another plugin owns the handler, wrap rather than replace
        if (str_contains($definedIn, 'another-plugin')) {
            error_log('[MyPlugin] Detected conflicting exception handler from another-plugin — wrapping.');
            // Proceed with wrapping (Pattern 1 above)
            return;
        }
    }

    // Safe to register directly
    set_exception_handler(fn(Throwable $e) => my_plugin_handle_exception($e));
}

 

This kind of defensive check was impossible before PHP 8.5 without replacing the handler in the process.

Pattern 3: Debug Toolbars and Dev-Mode Error Pages

Tools like Whoops, Symfony’s error handler, and Laravel’s Ignition register rich, styled exception handlers. A common need: detect whether such a handler is already active and skip redundant registration:

<?php
function maybe_register_dev_error_page(): void {
    // Only in development
    if (getenv('APP_ENV') !== 'local') {
        return;
    }

    $existing = get_exception_handler();

    // If Whoops or another dev handler is already installed, don't double-register
    if ($existing instanceof \Whoops\Run) {
        return;
    }

    // Check by class name if it's a closure wrapping a known handler
    if ($existing !== null) {
        $ref = new ReflectionFunction(
            $existing instanceof Closure ? $existing : Closure::fromCallable($existing)
        );
        // Check if the closure's file origin suggests it's a framework handler
        if (str_contains($ref->getFileName() ?? '', 'symfony/error-handler')) {
            return; // Symfony's error handler is active — trust it
        }
    }

    // Safe to install our own
    (new \Whoops\Run())->register();
}

Pattern 4: Integration with Monolog and Sentry

Here’s the pattern that matters most for production applications — wrapping PHP’s handler system into a structured logger:

<?php
use Monolog\Logger;
use Monolog\Handler\StreamHandler;
use Monolog\Handler\SlackWebhookHandler;

class ProductionErrorHandler {
    private Logger $logger;
    private ?callable $previousExceptionHandler;
    private ?callable $previousErrorHandler;

    public function __construct() {
        $this->logger = new Logger('app');
        $this->logger->pushHandler(new StreamHandler('/var/log/app/error.log', Logger::ERROR));
        $this->logger->pushHandler(new SlackWebhookHandler(
            getenv('SLACK_WEBHOOK_URL'),
            channel: '#alerts',
            level: Logger::CRITICAL
        ));
    }

    public function register(): void {
        // Capture existing handlers BEFORE replacing them
        $this->previousExceptionHandler = get_exception_handler();
        $this->previousErrorHandler     = get_error_handler();

        set_exception_handler([$this, 'handleException']);
        set_error_handler([$this, 'handleError']);
    }

    public function handleException(Throwable $e): void {
        $this->logger->critical($e->getMessage(), [
            'exception'  => get_class($e),
            'file'       => $e->getFile(),
            'line'       => $e->getLine(),
            'trace'      => $e->getTraceAsString(),
            'request_id' => $_SERVER['HTTP_X_REQUEST_ID'] ?? uniqid('req_'),
        ]);

        // Chain to the previous handler if one existed
        if ($this->previousExceptionHandler !== null) {
            ($this->previousExceptionHandler)($e);
        }
    }

    public function handleError(int $severity, string $message, string $file, int $line): bool {
        // Convert errors into ErrorException so they're caught by the exception handler
        if (error_reporting() & $severity) {
            throw new \ErrorException($message, 0, $severity, $file, $line);
        }
        return false; // Let PHP handle non-reported errors
    }

    public function restore(): void {
        // Useful in tests — restore original handlers after each test case
        set_exception_handler($this->previousExceptionHandler);
        set_error_handler($this->previousErrorHandler);
    }
}

// Bootstrap
$handler = new ProductionErrorHandler();
$handler->register();

The restore() method is the crucial addition get_exception_handler() enables in testing: PHPUnit can now verify that test-registered handlers get properly cleaned up between test runs, and you can write tests that temporarily install handlers and then fully restore the original state:

<?php
class ErrorHandlerTest extends \PHPUnit\Framework\TestCase {
    private ?callable $originalExceptionHandler;
    private ?callable $originalErrorHandler;

    protected function setUp(): void {
        // Save state BEFORE the test installs anything
        $this->originalExceptionHandler = get_exception_handler();
        $this->originalErrorHandler     = get_error_handler();
    }

    protected function tearDown(): void {
        // Fully restore — regardless of what the test did
        set_exception_handler($this->originalExceptionHandler);
        set_error_handler($this->originalErrorHandler);
    }

    public function testCustomHandlerRegistration(): void {
        $handler = new ProductionErrorHandler();
        $handler->register();

        // Verify our handler is now active
        $active = get_exception_handler();
        $this->assertIsCallable($active);

        // tearDown() will restore the original state
    }
}

This test pattern was effectively impossible before PHP 8.5. The only workaround was tracking handler state manually in a global variable — fragile, and it required cooperation from every library that touched handlers.

Integrating Both Features: A Complete Production Error Bootstrap

Putting both PHP 8.5 additions together into a single production bootstrap:

<?php
// bootstrap/error_handling.php

declare(strict_types=1);

use Monolog\Logger;
use Monolog\Handler\StreamHandler;
use Monolog\Formatter\JsonFormatter;

/**
 * PHP 8.5 gives us:
 * 1. Fatal error stack traces in logs automatically
 * 2. Safe handler inspection before wrapping
 */
class ErrorBootstrap {
    public static function install(Logger $logger): void {
        // PHP 8.5: Read existing handlers before touching them
        $existingExceptionHandler = get_exception_handler();
        $existingErrorHandler     = get_error_handler();

        // Exception handler: structured logging + chain
        set_exception_handler(function (Throwable $e) use ($logger, $existingExceptionHandler) {
            $context = [
                'type'       => get_class($e),
                'message'    => $e->getMessage(),
                'file'       => $e->getFile(),
                'line'       => $e->getLine(),
                // PHP 8.5: for fatal errors, this now contains the full call stack
                // for exception-converted fatals via ErrorException
                'trace'      => $e->getTraceAsString(),
                'request'    => [
                    'method' => $_SERVER['REQUEST_METHOD'] ?? 'CLI',
                    'uri'    => $_SERVER['REQUEST_URI'] ?? '',
                    'id'     => $_SERVER['HTTP_X_REQUEST_ID'] ?? '',
                ],
            ];

            $logger->critical('Unhandled exception', $context);

            // Chain to the previous handler if one existed
            if ($existingExceptionHandler !== null) {
                ($existingExceptionHandler)($e);
            } else {
                // Default: clean JSON error for APIs, HTML for browsers
                $accept = $_SERVER['HTTP_ACCEPT'] ?? '';
                http_response_code(500);
                if (str_contains($accept, 'application/json')) {
                    header('Content-Type: application/json');
                    echo json_encode(['error' => 'Internal Server Error']);
                } else {
                    echo '<h1>500 Internal Server Error</h1>';
                }
            }
        });

        // Error handler: convert non-fatal PHP errors to exceptions so they
        // flow through the same exception handler above
        set_error_handler(function (
            int $severity, string $message, string $file, int $line
        ) use ($existingErrorHandler): bool {
            // Only handle errors within the current error_reporting level
            if (!(error_reporting() & $severity)) {
                return false;
            }

            // Let the existing handler run first if there was one
            if ($existingErrorHandler !== null) {
                ($existingErrorHandler)($severity, $message, $file, $line);
            }

            throw new \ErrorException($message, 0, $severity, $file, $line);
        });

        // Shutdown handler: catch truly fatal errors that bypass the error handler.
        // In PHP 8.5, these now include stack traces in their message string.
        register_shutdown_function(function () use ($logger) {
            $error = error_get_last();
            if ($error && in_array($error['type'], [E_ERROR, E_CORE_ERROR, E_COMPILE_ERROR], true)) {
                $logger->emergency('Fatal error', [
                    'type'    => $error['type'],
                    // PHP 8.5: $error['message'] now includes the full stack trace
                    'message' => $error['message'],
                    'file'    => $error['file'],
                    'line'    => $error['line'],
                ]);
            }
        });
    }
}

// Install
$logger = new Logger('app');
$handler = new StreamHandler('/var/log/app/app.log');
$handler->setFormatter(new JsonFormatter());
$logger->pushHandler($handler);

ErrorBootstrap::install($logger);

The Complete Before/After Summary

Scenario PHP 8.4 and below PHP 8.5
Call to undefined function Error message + line only Full stack trace
Property access on null Error message + line only Full stack trace
Type error in strict mode Full trace (already an exception) Full trace
Memory exhaustion Error message only, no trace Partial trace from emergency buffer
Parse error No stack trace (can’t) No stack trace (impossible pre-execution)
get_exception_handler() Not available Returns current handler or null
get_error_handler() Not available Returns current handler or null
Handler inspection Forces handler replacement Pure read, no side effects
Test isolation Manual tracking required Full save/restore via getters
Library handler chaining Error-prone workarounds Clean, non-destructive inspection

 

Why These Two Features Belong Together

They’re shipped in the same release for a reason: fatal error stack traces are most valuable when they flow through a properly configured exception handler chain. get_exception_handler() makes it dramatically easier to build that chain correctly — without the destructive read-to-inspect workaround that made handler composition fragile for twenty years.

Together, they bring PHP’s fatal error observability to parity with what every modern runtime (Node.js, Python, Java, Ruby) has offered for years, while doing it in a way that’s backward-compatible with every existing handler registration pattern in the ecosystem.

For production PHP applications, the upgrade path is purely additive: no code changes needed to benefit from fatal error traces (they appear in existing log streams automatically), and get_error_handler()/get_exception_handler() are opt-in improvements for any code that registers or wraps handlers.

 

Previous Article

Automating Tasks with PHP and Cron Jobs: The Production Developer's Guide

Next Article

PHP 8.5 Internationalization: The Complete, Accurate Guide to What Actually Changed

Write a Comment

Leave a Comment

Your email address will not be published. Required fields are marked *

Subscribe to our Newsletter

Subscribe to our email newsletter to get the latest posts delivered right to your email.
Pure inspiration, zero spam ✨