Back to blog
How-to·June 23, 2026·4 min read

How to Send a PHP Push Notification to Your iPhone

Send a PHP push notification to your iPhone with one cURL call — no SDK, no APNs setup. Copy-paste code, a reusable function, plus Laravel examples.

How to Send a PHP Push Notification to Your iPhone

Your nightly import script choked on row 40,000. The cron that runs it swallowed the fatal error, the log file rotated a day later, and you found out the whole pipeline had been dead for three days — because a colleague asked why the dashboard numbers stopped moving.

PHP runs a lot of unglamorous background work: cron-driven imports, queue workers, WordPress scheduled tasks, a Laravel job quietly retrying itself into the void. When one of them dies, the default feedback channel is usually nothing at all — or an email that lands in a folder you opened last in March.

So here's the fix: send a push notification straight to your iPhone from PHP with a single HTTP POST. No SDK, no APNs certificates, no Firebase project. Just cURL — which is already compiled into basically every PHP install you'll ever touch.

Get a key first (30 seconds)

Sign in at thenotification.app with your Apple ID — no signup form, no email to verify, no separate API console to click through. Create an app, copy its app_key, and that's the only credential you'll ever pass. Keep it in an environment variable so it never lands in your repo:

export NOTIFICATION_APP_KEY="your_app_key_here"

The 5-line version

This is the whole thing. One curl_* call, a JSON body, two headers. Paste it at the bottom of the script you care about and you're done:

<?php

$ch = curl_init('https://thenotification.app/api/sendNotification');
curl_setopt_array($ch, [
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_POST           => true,
    CURLOPT_HTTPHEADER     => [
        'Content-Type: application/json',
        'app_key: ' . getenv('NOTIFICATION_APP_KEY'),
    ],
    CURLOPT_POSTFIELDS     => json_encode([
        'title' => 'Import finished',
        'body'  => '4,812 rows processed in 38s.',
    ]),
]);
curl_exec($ch);
curl_close($ch);

The API takes four fields: title and body are required, link and image are optional. Add a link and tapping the notification opens that URL — handy for jumping straight to the failing build or the broken record. The full list is in the API reference.

No cURL extension on the box? PHP's stream wrapper does the same job with zero dependencies:

<?php

file_get_contents(
    'https://thenotification.app/api/sendNotification',
    false,
    stream_context_create([
        'http' => [
            'method'  => 'POST',
            'header'  => "Content-Type: application/json\r\n"
                       . 'app_key: ' . getenv('NOTIFICATION_APP_KEY') . "\r\n",
            'content' => json_encode([
                'title' => 'Import finished',
                'body'  => 'All rows processed.',
            ]),
        ],
    ])
);

A reusable function

The throwaway version is fine for a one-off. For anything you'll call more than once, wrap it and actually check the status code — a notification that silently failed to send is worse than no notification, because now you trust a channel that's lying to you.

<?php

function notify(string $title, string $body, ?string $link = null): bool
{
    $payload = ['title' => $title, 'body' => $body];
    if ($link !== null) {
        $payload['link'] = $link;
    }

    $ch = curl_init('https://thenotification.app/api/sendNotification');
    curl_setopt_array($ch, [
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_POST           => true,
        CURLOPT_TIMEOUT        => 10,
        CURLOPT_HTTPHEADER     => [
            'Content-Type: application/json',
            'app_key: ' . getenv('NOTIFICATION_APP_KEY'),
        ],
        CURLOPT_POSTFIELDS     => json_encode($payload),
    ]);

    $response = curl_exec($ch);
    $status   = curl_getinfo($ch, CURLINFO_HTTP_CODE);
    curl_close($ch);

    return $status === 200;
}

A non-200 status tells you exactly what went wrong: 401 means the app_key is missing or wrong, and 429 means you've hit your quota (the body comes back as "Monthly message limit exceeded"). The codes are listed on the API reference. Now your error handler reads like plain English:

<?php

try {
    run_nightly_import();
} catch (\Throwable $e) {
    notify(
        'Import failed',
        $e->getMessage(),
        'https://yourapp.com/admin/jobs'
    );
    throw $e;
}

The CURLOPT_TIMEOUT matters more than it looks. Without it, a slow network call from inside a web request can hang the request that triggered it. Ten seconds is generous for a single POST; tune it down if you're calling notify() somewhere a user is waiting.

Drop it into Laravel

On Laravel you don't even need cURL boilerplate — the HTTP client wraps all of it. This is the canonical spot to put it: the failed() hook of a queued job, so you get pinged the moment a worker gives up after its last retry.

<?php

use Illuminate\Support\Facades\Http;

public function failed(\Throwable $exception): void
{
    Http::withHeaders([
        'app_key' => env('NOTIFICATION_APP_KEY'),
    ])->post('https://thenotification.app/api/sendNotification', [
        'title' => 'Queue job failed: ' . class_basename($this),
        'body'  => $exception->getMessage(),
    ]);
}

Same idea works anywhere — a Symfony messenger handler, a WordPress wp_schedule_event callback, a raw cron one-liner. If PHP can make an HTTP request, it can send the notification. The same trick works from the shell too; if you've got cron jobs that aren't PHP, see getting notified when a cron job fails.

WordPress, without the cURL boilerplate

If you're on WordPress — and a huge slice of PHP in the wild is — skip curl_init entirely and use the HTTP API that ships with core. The natural place to hook it is a scheduled task that fails quietly, which is exactly the kind of thing nobody notices for days:

<?php

add_action('my_nightly_sync', function () {
    $result = run_sync();

    if (is_wp_error($result)) {
        wp_remote_post('https://thenotification.app/api/sendNotification', [
            'headers' => [
                'Content-Type' => 'application/json',
                'app_key'      => MY_NOTIFICATION_APP_KEY,
            ],
            'body' => wp_json_encode([
                'title' => 'WP nightly sync failed',
                'body'  => $result->get_error_message(),
            ]),
        ]);
    }
});

Define MY_NOTIFICATION_APP_KEY in wp-config.php next to your other secrets, not in the theme. wp_remote_post handles the transport, retries, and SSL for you — it's the same call WordPress uses internally, so it behaves correctly on locked-down hosts where raw cURL sometimes doesn't.

Fire one to check it works

Before you trust it with a real failure, prove the pipe end to end. A single line from the CLI does it — if your phone buzzes, every example above will too:

php -r '$c=curl_init("https://thenotification.app/api/sendNotification");curl_setopt_array($c,[CURLOPT_POST=>true,CURLOPT_HTTPHEADER=>["Content-Type: application/json","app_key: ".getenv("NOTIFICATION_APP_KEY")],CURLOPT_POSTFIELDS=>json_encode(["title"=>"Test","body"=>"It works."])]);curl_exec($c);'

The honest tradeoff

Fair warning on the free tier: it's 100 notifications total, not per month. That's a lifetime bucket, and it's perfect for the "tell me when something breaks" jobs that should almost never fire. Wire notify() into a loop or a chatty queue that fails twenty times a night and you'll drain it in a week. Pro lifts that to 1,000 a month for $2.99 — but the better move is to only notify on the events that actually deserve your attention. The full breakdown is on the rate limits page.

That's it

One POST, two headers, a JSON body — the same pattern works in any language, which is the point. If you're coming from another stack, the Python version is line-for-line the same idea. Worth a look if you're tired of finding out about dead jobs from someone else: thenotification.app.

Stop babysitting your scripts.

Free to download. Free tier available. Swiss-hosted, private by design.

Get the app →