chiiya/passes
chiiya/passes is a Laravel package for creating Apple Wallet passes. Build pass models with a familiar Eloquent-style API, add images, barcodes, and fields, then sign and serve ready-to-download .pkpass files directly from your PHP application.
Install the package and publish its configuration:
composer require chiiya/passes
php artisan vendor:publish --tag="passes-config"
config/passes.php is the first file to read. It is split into an apple and a google block, and everything the library does is driven from those credentials. Get them in place before writing any pass code, because there is no meaningful "dry run" mode — signing fails late if a certificate or key is missing.
Apple Wallet needs three things from your Apple Developer account: the Pass Type ID certificate (.p12 or .pem + password), its private key, and the Apple WWDR intermediate certificate. Point the config at their filesystem paths (or pass the contents directly, depending on the version).
Google Wallet needs a service account JSON with the Wallet Object Issuer scope enabled, plus your Issuer ID from the Google Wallet Business Console. The service account email must be granted access to that issuer.
Your first use case should be a single, boring pass — a store card or event ticket — rendered to a file on disk. Something like:
use Chiiya\Passes\Apple\PassFactory;
use Chiiya\Passes\Apple\Passes\StoreCard;
$pass = (new StoreCard)
->setOrganizationName('Acme Coffee')
->setDescription('Loyalty card')
->setSerialNumber('member-1234')
->setLogoText('Acme')
->setForegroundColor('rgb(255, 255, 255)')
->setBackgroundColor('rgb(30, 30, 30)');
$path = app(PassFactory::class)->create($pass)->save(storage_path('passes/member-1234.pkpass'));
Open the resulting .pkpass in Finder/Preview on macOS (or AirDrop it to a device) to confirm the certificate chain, images, and pass.json are all valid. Do this before you build the download endpoint — debugging a signed archive is far easier than debugging an HTTP response.
For Google, the flow is class-then-object: create a LoyaltyClass/EventTicketClass once per program, then a LoyaltyObject per user. The package gives you both, plus a SaveLink/JWT helper. Start by generating a save URL for one test object and opening it on an Android device with Wallet installed.
Wrap the package in your own service. Do not sprinkle PassFactory calls through controllers. Create a WalletPassService (or per-domain services like LoyaltyPassService, TicketPassService) that owns serial-number generation, image resolution, and persistence. The library objects are mutable builders — they are not models, and you do not want them leaking into your domain layer.
Persist the pass data, not just the file. Apple passes with webServiceURL + authenticationToken must be re-issued identically (same serial number, same auth token) on every update, and your web service endpoints must return the latest signed .pkpass. Store the serial number, auth token, and the source data in your own table, then regenerate on demand from that record. Treat the .pkpass as a cacheable artifact with an ETag, not as the source of truth.
Use the "update" path for push notifications. When a user's balance changes, update your record and call Apple's APNs push endpoint with an empty payload to the push token registered against that serial. Your webServiceURL then serves the freshly signed pass. This is the part most people underestimate — the library signs passes; the device registration, push, and log endpoints are yours to implement. Check the docs for the exact PassKit Web Service routes; they are a small but strict contract (/v1/devices/{device}/registrations/{passType}/{serial}, etc.).
Google: separate class lifecycle from object lifecycle. Classes are long-lived and rarely change; objects change per user and per event. Keep a google_classes table or config map so your code looks up classId ({issuerId}.{suffix}) rather than constructing strings inline. Use addMessage() for notification text on updates, and setState('EXPIRED') rather than deleting objects — deletion invalidates the user's saved pass.
Template the visuals, don't hardcode them. Apple passes require icon.png, [email protected], [email protected], and typically logo.png plus @2x variants; Google wants hero/logo images by URL. Put these in a per-brand directory and resolve them through a small PassAssets resolver keyed by brand or program. This is exactly the kind of thing that gets duplicated across five controllers otherwise.
Generate in a queued job for bulk campaigns. Signing is CPU bound (zip + crypto) and the Google API is network bound. For "issue 50,000 loyalty cards" or "regenerate all passes after a program-wide change", queue it. Chunk Google API calls — the Wallet API has per-second quotas, and the package will happily let you hammer it.
Testing: assert against the generated pass.json payload (the library exposes the array/JSON representation before zipping) rather than against the binary .pkpass. For Google, bind a fake/stub HTTP client so tests never touch the real API; the Google objects serialize to plain arrays, which makes snapshot-style assertions easy. Keep a checked-in set of test certificates (self-signed or a dedicated dev Pass Type ID) so your test suite can actually sign.
.p12/.pem, reads validTo, and alerts well before expiry. This is the single highest-value piece of glue code you can write around this package.serialNumber must be unique per Pass Type ID, and Apple deduplicates by it. Reusing a serial with different content causes confusing "pass already exists" behaviour on device. Namespace your serials (loyalty-{id}, ticket-{id}).authenticationToken must be at least 16 characters. Apple rejects shorter tokens at install time, not at signing time, so you find out on the device.rgb(r, g, b) strings on Apple, hex on Google. Easy to mix up. Also, foreground/background contrast matters — a dark logo on a dark background is invisible and users will notice before you do.messageEncoding: iso-8859-1 unless you set UTF-8 explicitly. Scanners on older hardware choke otherwise. Set format (PKBarcodeFormatQR, PDF417, Aztec, Code128) deliberately per program.2026-05-29T14:00:00+02:00). Passing a Unix timestamp or a naive datetime produces a pass that installs but shows wrong times, or is rejected. Use Carbon::toIso8601String() and be explicit about the venue timezone.pass.json at the root, manifest.json with SHA-1 of every file, signature last. If you ever hand-modify or re-zip, you will break it. Always regenerate through the package rather than patching an existing .pkpass.application/vnd.apple.pkpass for Apple. application/octet-stream will cause Safari to download it as a file instead of prompting Wallet, and on some iOS versions it fails entirely. Don't forget Content-Disposition: attachment; filename="pass.pkpass".SaveLink/JWT has a short TTL, and a stale link produces a blank or error screen with no useful diagnostics.{issuerId}.{suffix} and the issuer must own the class. If you switch environments, use a different suffix per environment (prod-loyalty, dev-loyalty) rather than the same class ID with different content.@2x, or PNGs saved as JPEGs renamed. Apple silently drops invalid images; the pass installs and looks broken. Run sips -g pixelWidth -g pixelHeight (macOS) over your assets once and record the required sizes in a comment.ZipArchive, openssl_sign, or the Google HTTP client — not from your own code. Log the full pass.json (it contains no secrets) alongside any failure; nine times out of ten the answer is in the payload, not the stack trace.How can I help you explore Laravel packages today?