April 25th, 2026

Generating Apple and Google Wallet Passes with Laravel

Generating Apple and Google Wallet Passes with Laravel
Sponsored by
Table of Contents

You know that "Add to Apple Wallet" button on a boarding pass email, or the Google Wallet save link on a concert ticket. Until recently, getting one of those into a Laravel app meant wading through Apple's PassKit specification, learning how to sign a manifest with a P12 certificate, figuring out the APNs flow for live updates, and then doing the whole thing again for Google Wallet's REST API.

spatie/laravel-mobile-pass collapses all of that into a builder API and a single Eloquent model. You describe the pass, call save(), and you get a model that knows how to serve itself to both platforms.

The package is currently tagged 0.0.1. The README still says "IN DEVELOPMENT: DO NOT USE IN PRODUCTION (YET)", so treat anything you ship as a soft launch. By the time you read this it may be cleared for production. Check the repo before betting payroll on it.

What you can build

The supported pass types map onto Apple's Wallet categories and Google Wallet's equivalents:

  • Boarding passes (airline, train, bus, boat, generic)
  • Event tickets
  • Coupons
  • Loyalty cards and store cards
  • Generic passes for anything that doesn't fit the above

A pass on a phone isn't a static image. It carries structured data (seat, gate, balance, expiry), supports a barcode or QR code, can pop a notification on the lock screen when its location matches, and on the Apple side can even carry Wi-Fi credentials or NFC payloads. The package surfaces all of that.

The other thing worth flagging up front: passes update live. When you change a field server-side, both Apple and Google notify the device, and the pass on the user's lock screen rewrites itself. No new email, no second download. That single feature is most of the reason to ship wallet passes in the first place.

Requirements and credentials

Before installing, get the Apple side sorted, since that is the part that takes the longest.

You need an active Apple Developer Program membership ($99/year) and three things from the Apple Developer portal:

  1. A Pass Type ID, a reverse-DNS string like pass.com.acme.tickets
  2. A Pass Type ID Certificate exported as a .p12 file
  3. A Push Notification Certificate, used by APNs to ping devices when a pass updates

Spatie's docs walk through the certificate dance in detail. The condensed version: register the Pass Type ID, generate a Certificate Signing Request from Keychain Access, upload it to Apple, download the resulting .cer, drop it into Keychain, then export it back out as a .p12 with a password.

Google Wallet is less ceremonial. You create a Google Cloud project, enable the Google Wallet API, and download a service account JSON key. The package uses that key to sign save links and to push updates.

Install and configure

Pull the package in:

1composer require spatie/laravel-mobile-pass

Publish and run the migration:

1php artisan vendor:publish \
2 --tag="mobile-pass-migrations"
3php artisan migrate

Publish the config (optional, but useful for the Apple credentials):

1php artisan vendor:publish \
2 --tag="mobile-pass-config"

The published config/mobile-pass.php mostly maps to environment variables:

1'apple' => [
2 'organisation_name' => env(
3 'MOBILE_PASS_APPLE_ORGANISATION_NAME'
4 ),
5 'type_identifier' => env(
6 'MOBILE_PASS_APPLE_TYPE_IDENTIFIER'
7 ),
8 'team_identifier' => env(
9 'MOBILE_PASS_APPLE_TEAM_IDENTIFIER'
10 ),
11 'certificate_path' => env(
12 'MOBILE_PASS_APPLE_CERTIFICATE_PATH'
13 ),
14 'certificate_password' => env(
15 'MOBILE_PASS_APPLE_CERTIFICATE_PASSWORD'
16 ),
17 // ...
18],

The team identifier is the ten-character ID at the top of your Apple Developer account. The type identifier is the Pass Type ID you registered. Drop the .p12 somewhere outside the public path and point MOBILE_PASS_APPLE_CERTIFICATE_PATH at it. If you'd rather not ship a file alongside the code (Forge deploys, containers), set MOBILE_PASS_APPLE_CERTIFICATE_CONTENTS instead with the raw certificate contents.

Last piece: register the package's routes. These handle device registration callbacks from Apple and serve the signed download URLs.

1// routes/web.php
2Route::mobilePass();

That single macro registers everything the Apple Wallet web service spec requires. If you've ever implemented this by hand, you know what's behind it: device register and unregister endpoints, a "get serial numbers updated since" endpoint, the .pkpass download endpoint, and a logging endpoint that Apple uses to report errors. The macro wires all of them.

Building a boarding pass

Each pass type has a dedicated builder. Here's a boarding pass for a flight from Abu Dhabi to London:

1use Spatie\LaravelMobilePass\Builders\Apple\AirlinePassBuilder;
2use Spatie\LaravelMobilePass\DataObjects\FieldContent;
3use Spatie\LaravelMobilePass\DataObjects\Image;
4use Spatie\LaravelMobilePass\DataObjects\Seat;
5 
6$mobilePass = AirlinePassBuilder::make()
7 ->setOrganisationName('Etihad')
8 ->setSerialNumber('TKT-' . $ticket->id)
9 ->setDescription('Boarding pass')
10 ->setHeaderFields(
11 FieldContent::make('flight-no')
12 ->withLabel('Flight')
13 ->withValue('EY066'),
14 FieldContent::make('seat')
15 ->withLabel('Seat')
16 ->withValue('66F'),
17 )
18 ->setPrimaryFields(
19 FieldContent::make('departure')
20 ->withLabel('Abu Dhabi Intl')
21 ->withValue('AUH'),
22 FieldContent::make('destination')
23 ->withLabel('London Heathrow')
24 ->withValue('LHR'),
25 )
26 ->setSecondaryFields(
27 FieldContent::make('name')
28 ->withLabel('Passenger')
29 ->withValue($ticket->passenger_name),
30 FieldContent::make('gate')
31 ->withLabel('Gate')
32 ->withValue('D68'),
33 )
34 ->setAuxiliaryFields(
35 FieldContent::make('departs')
36 ->withLabel('Departs')
37 ->withValue(
38 $ticket->departs_at->toIso8601String()
39 ),
40 FieldContent::make('class')
41 ->withLabel('Class')
42 ->withValue('Economy'),
43 )
44 ->setIconImage(
45 Image::make(
46 x1Path: storage_path(
47 'app/passes/icon.png'
48 ),
49 ),
50 )
51 ->setDepartureAirportCode('AUH')
52 ->setDestinationAirportCode('LHR')
53 ->setSeats(Seat::make(number: '66F'))
54 ->save();

Three things worth pointing out here.

The field zones (header, primary, secondary, auxiliary) map directly onto Apple's pass layout. Header sits at the top right, primary is the big text, secondary and auxiliary fill the rows below. Get the zones right and the pass looks like Apple drew it. Cram everything into primary and it looks broken.

Field keys (flight-no, seat, gate) are stable identifiers, not labels. The label is what the user sees. The key is what you reference when you push an update. Pick names you won't regret in three months.

Semantic fields (setDepartureAirportCode, setSeats) feed Apple's Siri integration. Set them and your pass shows up in Siri suggestions like "You have a flight to London in three hours". Skip them and the pass still works, but the user loses the system-level smarts.

save() returns a MobilePass Eloquent model. That model is the only thing you need to keep around.

Putting the pass in front of the user

The MobilePass model implements Laravel's Responsable, so the simplest delivery is to return it from a controller:

1use Spatie\LaravelMobilePass\Models\MobilePass;
2 
3class AddToWalletController
4{
5 public function __invoke(MobilePass $pass)
6 {
7 return $pass;
8 }
9}

Hit the route on an iPhone and Wallet opens with the pass. Hit it on Android and the user gets redirected to a pay.google.com save link. One controller, both platforms.

For an explicit URL (a button on a confirmation page, a link in a Slack message, anything that isn't a controller return), call addToWalletUrl():

1$url = $mobilePass->addToWalletUrl();

For QR codes on printed confirmations, wrap the URL with any QR library:

1use SimpleSoftwareIO\QrCode\Facades\QrCode;
2 
3QrCode::size(240)->generate(
4 $mobilePass->addToWalletUrl()
5);

The Apple side also implements Attachable, so you can attach a .pkpass file directly to a Mailable:

1class TicketPurchased extends Mailable
2{
3 public function __construct(
4 public User $user,
5 public MobilePass $mobilePass,
6 ) {}
7 
8 public function envelope(): Envelope
9 {
10 return new Envelope(
11 subject: 'Your ticket'
12 );
13 }
14 
15 public function content(): Content
16 {
17 return new Content(
18 markdown: 'mail.ticket-purchased'
19 );
20 }
21 
22 public function attachments(): array
23 {
24 return [$this->mobilePass];
25 }
26}

Mail.app on iPhone recognises the application/vnd.apple.pkpass MIME type and surfaces an "Add to Wallet" button right inside the message. Google passes can't be attached the same way (Google hosts them), so for Android stick to the URL or QR code routes.

Live updates: the part that earns its keep

Almost every wallet pass changes after issuance. Gates change. Seats get reassigned. Loyalty balances tick up. Coupon expiries get pushed. A static pass becomes a liability the moment something on the server doesn't match what the user is staring at.

The package handles updates per platform.

For Apple, call updateField on the model:

1$mobilePass->updateField('seat', '13A');

That single call updates the pass record, signs a new .pkpass payload, and notifies APNs. Apple pings the device, the device fetches the new pass from the package's Route::mobilePass() endpoints, and the lock screen updates within a minute.

If you want a notification banner on the user's phone when the field changes, pass a changeMessage:

1$mobilePass->updateField(
2 'seat',
3 '13A',
4 changeMessage:
5 'Your seat changed to :value',
6);

The :value placeholder gets the new field value at notification time. Once set, the message persists for that field, so future updates fire the same banner template until you overwrite it.

For multiple field changes in one update, drop to the builder so the device only gets pinged once:

1$mobilePass->builder()
2 ->updateField('seat', '13A')
3 ->updateField('gate', 'D68')
4 ->save();

Google passes work differently. There's no updateField. Instead, mutate the content array on the model and save:

1$content = $mobilePass->content;
2$content['googleObjectPayload']
3 ['ticketHolderName'] = 'John Lennon';
4 
5$mobilePass->update(['content' => $content]);

The package observes the change, dispatches NotifyGoogleOfPassUpdateAction, and patches the Object on Google's Wallet REST API. Google fans the update out to every device the pass lives on.

Both notification flows run through a PushPassUpdateJob. Out of the box that job runs synchronously. For low-traffic apps this is fine and errors surface immediately. For anything serious, push it onto a queue:

1MOBILE_PASS_QUEUE_CONNECTION=redis
2MOBILE_PASS_QUEUE_NAME=wallet

Now updateField returns instantly and the APNs or Google call happens in a worker. Standard Laravel queue conventions apply: retries, failed jobs, the lot.

Customising the moving parts

Two extension points cover most needs.

Actions handle the platform-specific calls: NotifyAppleOfPassUpdateAction, NotifyGoogleOfPassUpdateAction, RegisterDeviceAction, UnregisterDeviceAction. Extend any of them, register the subclass in config/mobile-pass.php, and your version runs instead. Useful for audit logging, retry policies, or sending yourself a Slack ping when a registration fails.

Models can be swapped the same way. The most common reason to swap MobilePass is to give the Apple download URL an expiration. By default, the package uses signedRoute, not temporarySignedRoute, since users routinely open these links days later on a fresh device. If your business model needs the link to die after an hour:

1namespace App\Models;
2 
3use Illuminate\Support\Facades\URL;
4use Spatie\LaravelMobilePass\Models\MobilePass
5 as BaseMobilePass;
6 
7class MobilePass extends BaseMobilePass
8{
9 protected function addToAppleWalletUrl(): string
10 {
11 return URL::temporarySignedRoute(
12 'mobile-pass.apple.download',
13 now()->addHour(),
14 ['mobilePass' => $this->id],
15 );
16 }
17}

Register it in config/mobile-pass.php under models.mobile_pass and you're done.

Try before you build

Spatie maintains a live demo at mobile-pass-demo.spatie.be. Generate every supported pass type, install it on your iPhone, and use the "simulate change" button to watch updates roll in. The source is at spatie/laravel-mobile-pass-demo and it's worth reading as a reference implementation. It's the fastest way to confirm your mental model of the API matches what actually ends up on the lock screen.

Production notes

The "in development" warning on the README is real. The 0.0.1 tag was published in March 2025. Before pointing this at anything that issues real boarding passes or coupon codes:

  • Pin the version in composer.json and read every release note before bumping.
  • Treat the certificate password and the Apple webservice secret as production credentials. Rotate them on the same schedule you rotate everything else.
  • Make sure your queue worker is running before you flip on MOBILE_PASS_QUEUE_CONNECTION. A silent queue means devices that never see the update.
  • Test with a real device, not the simulator. Wallet behaves differently on real iPhones, especially around lock-screen notifications and the relevance APIs.
  • Keep an eye on the Apple webservice host config. Apple talks to your server, not the other way around, so it has to be publicly reachable, on HTTPS, with a valid certificate.

Wrap-up

Wallet passes have always been a pain to ship from Laravel. The PassKit spec is dense, the certificate flow is opaque, and Google's API is a separate animal. spatie/laravel-mobile-pass is the first package I've seen that treats both platforms as the same problem and gives you a single Eloquent model that handles delivery, attachment, and live updates without you ever touching APNs directly.

It isn't 1.0 yet. Watch the changelog, file issues if you hit them, and don't roll it out under your most expensive use case until the production warning comes off. For anything new you're starting today, where the alternative is rolling your own PassKit signer or paying a SaaS per-pass, this is worth a close look.

Repo: github.com/spatie/laravel-mobile-pass
Docs: spatie.be/docs/laravel-mobile-pass

If you enjoyed this article, please consider supporting our work for as low as $5 / month.

Sponsor
Marian Pop

Written by

Marian Pop

Writing and maintaining @LaravelMagazine. Host of "The Laravel Magazine Podcast". Pronouns: vi/vim.

Comments

Stay Updated

Subscribe to our newsletter

Get latest news, tutorials, community articles and podcast episodes delivered to your inbox.

Weekly articles
We send a new issue of the newsletter every week on Friday.
No spam
We'll never share your email address and you can opt out at any time.