All articles
// developers

Apple Wallet Passes in Laravel and PHP: Issue, Serve and Update Them Without Owning the Signing

PHP packages generate a .pkpass and stop. Here is the queued job, controller and webhook that cover the rest in Laravel, with no signing code of your own.

Updated
Apple Wallet Passes in Laravel and PHP: Issue, Serve and Update Them Without Owning the Signing
Julio Song
11 min read

A Laravel developer we'll call Marta ships a membership feature on a Tuesday. People register, and an "Add to Apple Wallet" button shows up on the confirmation page. By Wednesday lunch there are two tickets in the queue. One member tapped the button on her iPhone and Safari offered to save a generic file. Another changed her surname in her profile, and the card in her Wallet still carries the old one.

Marta's code is not sloppy. The package she picked builds a valid .pkpass and signs it. What it doesn't do is tell Safari what the file is, or reach into a phone that already has the pass. Those two gaps are where most PHP wallet projects spend their second week.

This post builds the full path in Laravel: a model event, a queued job, a download route and a webhook receiver. You'll see where the PHP packages stop, what a hosted API takes off your plate, and the framework details that catch people out.

Where the PHP packages stop

The PHP world has two names that matter. The first is pkpass/pkpass, which Packagist describes as a PHP class for creating iOS Wallet passes. Its latest release at the time of writing is v2.5.1 from January 2026, and it has been installed more than four million times. That is a lot of installs for a library with a narrow job.

The second family is for Laravel. thenextweb/passgenerator was the original, and its Packagist release is 0.5.0 from March 2024. The byte5/laravel-passgenerator fork describes the original as no longer maintained, and the fork itself shipped v0.5 in April 2026 with support for Laravel 7 through 13. It is licensed GPL-3.0-or-later, which matters if you ship closed-source software and haven't read that license lately.

Both families do the same thing. You give them a pass definition, your image assets and your Apple certificate, and they hand back a signed .pkpass. That is real work, and it saves you from writing the manifest and the PKCS#7 signature yourself.

Here is what you still own after you install one:

  • The Apple Developer Program membership, the Pass Type ID and the .p12 export.
  • A calendar entry for the certificate renewal, because Apple certificates run for a year and a lapsed one stops new passes from installing.
  • The web service that registers devices, plus an APNs client to wake each phone when a pass changes.
  • Everything for Android, since these libraries only produce Apple's format.

If you want to own all of that, the packages are a fair starting point. If you want a membership card on two platforms by the end of the sprint, the second half of that list is the project.

The shape of the integration

Treat pass issuing like any other call to an outside service that your database triggers. A model event enqueues a job. The job calls the API and stores the pass ID it gets back. A controller sends the member to a download link. A webhook reports what happened afterward.

Passmint's REST API takes a template ID and field values and returns a signed pass. In test mode it signs with Passmint's own certificate, so you can run every snippet below without an Apple Developer account. The quickstart covers creating a key and a template.

Add two columns and your credentials:

php artisan make:migration add_passmint_to_members_table
// database/migrations/xxxx_add_passmint_to_members_table.php
Schema::table('members', function (Blueprint $table) {
    $table->string('passmint_pass_id')->nullable()->unique();
    $table->timestamp('wallet_added_at')->nullable();
});
// config/services.php
'passmint' => [
    'key' => env('PASSMINT_API_KEY'),
    'template' => env('PASSMINT_TEMPLATE_ID'),
    'webhook_secret' => env('PASSMINT_WEBHOOK_SECRET'),
],

Read the key through config(), not env(), in application code. Once you run php artisan config:cache, env() returns null outside config files, and a missing key in production is a miserable thing to debug.

A client in one class

Laravel already ships an HTTP client, so you need no SDK and no new line in composer.json. A small class keeps the base URL, the auth header and the idempotency header in one place.

<?php
// app/Services/Passmint.php

namespace App\Services;

use Illuminate\Http\Client\PendingRequest;
use Illuminate\Support\Facades\Http;

class Passmint
{
    private function api(?string $idempotencyKey = null): PendingRequest
    {
        $request = Http::baseUrl('https://api.passmint.com')
            ->withToken(config('services.passmint.key'))
            ->acceptJson()
            ->timeout(10);

        return $idempotencyKey
            ? $request->withHeaders(['Idempotency-Key' => $idempotencyKey])
            : $request;
    }

    public function createPass(array $body, string $idempotencyKey): array
    {
        return $this->api($idempotencyKey)->post('/v1/passes', $body)->throw()->json();
    }

    public function updatePass(string $passId, array $fields, string $idempotencyKey): array
    {
        return $this->api($idempotencyKey)
            ->patch("/v1/passes/{$passId}", ['field_values' => $fields])
            ->throw()
            ->json();
    }

    public function downloadLink(string $passId, int $expiresIn = 3600): array
    {
        return $this->api()
            ->post("/v1/passes/{$passId}/download-links", ['expires_in' => $expiresIn])
            ->throw()
            ->json();
    }
}

Two details earn their place. The Idempotency-Key header lets you retry a POST or PATCH without creating a duplicate, and the API replays the cached response for 24 hours. And ->throw() turns any 4xx or 5xx into a RequestException, which is what lets a queued job retry on its own instead of silently carrying on with an error body.

Issue the pass from a job, not a request

The tempting spot for this call is the controller that creates the member. Resist it. A network call to a third party inside a request makes your signup page as slow as their API, and as fragile.

Dispatch a job after the transaction commits instead. Calling afterCommit() on the dispatch does that for you, so the worker never runs before the row exists.

<?php
// app/Models/Member.php

protected static function booted(): void
{
    static::created(function (Member $member) {
        IssueWalletPass::dispatch($member->id)->afterCommit();
    });

    static::updated(function (Member $member) {
        if ($member->passmint_pass_id && $member->wasChanged(['name', 'points'])) {
            UpdateWalletPass::dispatch($member->id)->afterCommit();
        }
    });
}
<?php
// app/Jobs/IssueWalletPass.php

namespace App\Jobs;

use App\Models\Member;
use App\Services\Passmint;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Http\Client\RequestException;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;

class IssueWalletPass implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

    public int $tries = 5;

    public function __construct(public int $memberId) {}

    public function backoff(): array
    {
        return [10, 60, 300, 900];
    }

    public function handle(Passmint $passmint): void
    {
        $member = Member::findOrFail($this->memberId);

        if ($member->passmint_pass_id) {
            return;
        }

        try {
            $pass = $passmint->createPass([
                'template_id' => config('services.passmint.template'),
                'holder_email' => $member->email,
                'holder_name' => $member->name,
                'field_values' => [
                    'memberNumber' => (string) $member->number,
                    'points' => (string) $member->points,
                ],
            ], "member-{$member->id}-issue");
        } catch (RequestException $e) {
            $status = $e->response->status();

            if ($status < 500 && $status !== 429) {
                $this->fail($e);

                return;
            }

            throw $e;
        }

        $member->forceFill(['passmint_pass_id' => $pass['id']])->saveQuietly();
    }
}

The early return guards against a re-run. The idempotency key guards against the case the early return can't see: the API call succeeded, your worker died before the save, and the retry would otherwise mint a second pass. saveQuietly() keeps that write from firing your own updated listener.

The catch block separates two kinds of failure. A 400 means your payload is wrong, and five retries will fail five times, so the job fails at once. A 5xx or a 429 is worth waiting out, so it rethrows and the backoff does its job.

Field values are strings. Passing $member->points as an integer is the sort of mistake that works in Tinker and fails in the queue, so cast at the boundary.

The download route, and who may hit it

Here is the controller behind the button:

<?php
// app/Http/Controllers/WalletPassController.php

namespace App\Http\Controllers;

use App\Services\Passmint;

class WalletPassController extends Controller
{
    public function show(Passmint $passmint)
    {
        $member = auth()->user()->member;

        abort_if(! $member?->passmint_pass_id, 404);

        $link = $passmint->downloadLink($member->passmint_pass_id);

        return redirect()->away($link['url']);
    }
}
// routes/web.php
Route::get('/wallet-pass', [WalletPassController::class, 'show'])->middleware('auth');

Look at what the route leaves out: an ID in the path. It reads the member from the signed-in user. A signed pass carries a barcode, and a route like /members/{id}/pass that trusts the number in the URL gives anyone who can count a stranger's membership card. Scoping through auth()->user() removes that whole class of bug.

The link expires after an hour by default, and you can ask for anything from 60 seconds up to 7 days. Expiry protects the link, not the pass. A .pkpass that was downloaded before the link died is a signed file on the member's phone, and no expiry reaches it. For control over redemption, use the scanner and redemption policies.

If you sign passes yourself instead, this is where the header trap lives. Return the bytes with the content type application/vnd.apple.pkpass and a Content-Disposition of attachment, using response($bytes, 200, [...]). Use application/octet-stream or let Laravel guess, and Safari saves a generic download and never offers Wallet. That's what Marta's first member saw.

Updates: what changes on the phone

Apple's update model is a small protocol. At install, the phone registers a device and push token against a web service URL baked into the pass. When a field changes, your server sends an empty APNs push to each registered token. Wallet then calls back, asks which passes changed and fetches each one again, re-signed.

In Laravel that means five routes, a devices table, an APNs client with its own credentials, and pruning for tokens that die when someone deletes a pass. With the API, the whole thing is one PATCH inside a job:

<?php
// app/Jobs/UpdateWalletPass.php

class UpdateWalletPass implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

    public int $tries = 5;

    public function __construct(public int $memberId) {}

    public function backoff(): array
    {
        return [10, 60, 300, 900];
    }

    public function handle(Passmint $passmint): void
    {
        $member = Member::findOrFail($this->memberId);

        $passmint->updatePass($member->passmint_pass_id, [
            'memberNumber' => (string) $member->number,
            'points' => (string) $member->points,
        ], "member-{$member->id}-{$member->updated_at->timestamp}");
    }
}

Build the idempotency key from the version of the record, as above, rather than from the member alone. A key that never changes makes every update after the first replay the cached response and quietly do nothing. Reusing a key with a different body returns a 409, which is how the API tells you it happened.

Marta's surname bug lives here. Her updated listener never existed, so nothing told the phone. With the listener and the job, a profile edit reaches the pass on its own.

Hearing back: a webhook receiver

The last piece is knowing what happened. Passmint sends signed webhooks for events such as pass.added_to_wallet and pass.removed. The webhook docs list them all.

Put the route in routes/api.php so it skips CSRF, which a webhook can't satisfy. On a fresh Laravel 11 or later app, php artisan install:api creates that file.

<?php
// app/Http/Controllers/PassmintWebhookController.php

namespace App\Http\Controllers;

use App\Models\Member;
use Illuminate\Http\Request;

class PassmintWebhookController extends Controller
{
    private const TOLERANCE = 300;

    public function __invoke(Request $request)
    {
        $payload = $request->getContent();

        if (! $this->validSignature($payload, $request->header('passmint-signature'))) {
            return response('Invalid signature', 400);
        }

        $event = json_decode($payload, true);
        $passId = $event['data']['object']['pass']['id'] ?? null;

        match ($event['type'] ?? null) {
            'pass.added_to_wallet' => Member::where('passmint_pass_id', $passId)
                ->update(['wallet_added_at' => now()]),
            'pass.removed' => Member::where('passmint_pass_id', $passId)
                ->update(['wallet_added_at' => null]),
            default => null,
        };

        return response()->noContent();
    }

    private function validSignature(string $payload, ?string $header): bool
    {
        $parts = [];

        foreach (explode(',', (string) $header) as $pair) {
            [$key, $value] = array_pad(explode('=', $pair, 2), 2, '');
            $parts[trim($key)] = trim($value);
        }

        $timestamp = (int) ($parts['t'] ?? 0);
        $signature = $parts['v1'] ?? '';

        if (! $timestamp || ! $signature || abs(time() - $timestamp) > self::TOLERANCE) {
            return false;
        }

        $expected = hash_hmac(
            'sha256',
            "{$timestamp}.{$payload}",
            config('services.passmint.webhook_secret'),
        );

        return hash_equals($expected, $signature);
    }
}
// routes/api.php
Route::post('/webhooks/passmint', PassmintWebhookController::class);

Three details matter. $request->getContent() returns the exact bytes that were signed, and re-encoding parsed input would change whitespace and break the HMAC. hash_equals compares in constant time, which === does not. And the five-minute tolerance rejects old signatures, so a captured request can't be replayed next week.

Failed deliveries retry up to seven times, and your endpoint has 10 seconds to return a 2xx. An update that sets a timestamp is safe to run twice, which is the property you want.

What this costs you, and what it doesn't

Count what you wrote: one small client, two jobs, a thin controller and a webhook receiver. You never touched OpenSSL, you registered no device table, and you hold no push credential. The certificate renewal that nobody puts on a calendar is Passmint's job, not yours.

The price is a dependency. Your signup flow now rests on an external API, which is why the jobs retry and why no member ever waits on the call. If owning the signing pipeline is a hard requirement, byte5/laravel-passgenerator is the right place to begin, and the controller advice above still applies.

The same model covers the other wallet too. Google Wallet uses signed JWTs with a different credential and a different update path, and through the API both wallets are the same call. The code above issues an Android pass with no second implementation.

If you're on Laravel and want to see it working, the quickstart gets a pass onto a real phone in about five minutes, and every snippet here runs against a test key. When you're ready for more, start at Passmint.

Primary sources

Common questions

Yes. byte5/laravel-passgenerator is a maintained fork of thenextweb/passgenerator, and its latest Packagist release is v0.5 from April 2026. It builds and signs a .pkpass from your own Apple certificates. It does not run the update web service or push to phones that already hold a pass, and it does not cover Google Wallet.
pkpass/pkpass is the long-standing choice. Packagist lists v2.5.1 from January 2026 and describes it as a PHP class for creating iOS Wallet passes. Like the Laravel packages, it generates and signs the file, so you still supply the certificate and build the update path yourself.
Return the bytes with the content type application/vnd.apple.pkpass and an attachment disposition. Safari only offers the file to Wallet when that content type is exact. If an API signs the pass for you, redirect to its download link instead and skip serving bytes.
No. Dispatch a queued job after the database transaction commits, and send an idempotency key so a retried job cannot create a second pass for the same member.
Julio Song

Technical content writer, Passmint

Julio is a technical content writer at Passmint. He writes about Apple PassKit, the Google Wallet API, and what breaks when wallet passes meet production traffic.

More from Julio Song →

Related reading

Share this article