eo/passbook-bundle
Symfony2 bundle that integrates the php-passbook library to generate signed .pkpass files for Apple Wallet. Configure your pass type and team identifiers, certificates, and output path, then create passes from your app.
First, the reality check. eo/passbook-bundle is a Symfony 2.1+ bundle (Eo\PassbookBundle\EoPassbookBundle) registered through AppKernel::registerBundles(). There is no service provider, no facade, no publishable config — it will not install or boot in a Laravel app. Do not run composer require eo/passbook-bundle and expect progress; the Symfony 2.x constraints will either fail resolution against a modern symfony/* tree or require --ignore-platform-reqs hacks you should never ship.
What you actually want is the thing the bundle wraps: eymengunay/php-passbook, which does the .pkpass assembly, manifest hashing, and PKCS#7 signing. The bundle is a thin DI wrapper around a pass_factory service. In Laravel you rebuild that wrapper yourself — roughly 60 lines of service provider — and skip Symfony entirely.
Where to look first, in order:
eymengunay/php-passbook documentation and API docs (linked from the bundle README), not the bundle README. The bundle README is only a config table plus "see php-passbook documentation."Resources/config/services.xml on GitHub — it shows exactly how pass_factory is constructed (which constructor args are certs, password, identifiers, output path). That's your porting blueprint.Provision certificates before writing code — this is the long pole, not the integration:
.p12 (with password).icon.png (29×29) and [email protected] (58×58). These are mandatory even when the docs make them sound optional.Store the .p12 and password outside the repo — storage/app/private/passbook/ in local dev, a secrets manager in production.
Config file (config/wallet.php) — mirror the bundle's config keys so the mapping is obvious:
return [
'pass_type_identifier' => env('PASSBOOK_PASS_TYPE_ID'),
'team_identifier' => env('PASSBOOK_TEAM_ID'),
'organization_name' => env('PASSBOOK_ORG_NAME'),
'p12_path' => env('PASSBOOK_P12_PATH'),
'p12_password' => env('PASSBOOK_P12_PASSWORD'),
'wwdr_path' => env('PASSBOOK_WWDR_PATH'),
'output_path' => storage_path('app/passes'),
'icon_path' => resource_path('passbook/icon.png'),
];
First use case worth shipping: a static coupon or membership card generated in a queued job, stored in S3, and delivered by a signed temporary URL. No Wallet web service, no APNs, no device registration table — generation only. That is the smallest thing that proves the pipeline works end to end.
Build a seam immediately. Wrap everything behind an interface so the 2018 library is swappable:
interface WalletPassGenerator
{
public function generate(PassDefinition $pass): GeneratedPass;
}
PassDefinition is your own DTO (serial number, description, fields, barcode, colors, relevant date, assets). GeneratedPass carries the bytes plus the serial number. The php-passbook adapter implements the interface; a future port or a maintained library replaces one class.
Service provider binds the adapter and nothing else resolves the library directly:
$this->app->singleton(WalletPassGenerator::class, function ($app) {
return new PhpPassbookGenerator($app['config']['wallet']);
});
Generate in a job for anything bulk. Building 10k passes synchronously in a request is a timeout waiting to happen (zip + RSA sign per pass). Queue it, write to S3 or storage/app/passes, persist the serial number.
Persist serial numbers and pass metadata from day one — passes table with serial_number, pass_type, user_id, generated_at, last_updated_at. Even if you ship generation-only, this is the table that lets you add updates later without a migration scramble. serialNumber must be unique per pass and is how Wallet identifies it forever.
Delivery pattern — stream, don't redirect:
return response()->streamDownload(
fn () => print($bytes),
"{$serial}.pkpass",
[
'Content-Type' => 'application/vnd.apple.pkpass',
'Content-Disposition' => 'attachment; filename="pass.pkpass"',
'Cache-Control' => 'no-store, no-cache, must-revalidate',
]
);
iOS ignores the response entirely if the MIME type isn't application/vnd.apple.pkpass, so set it explicitly even though streamDownload guesses.
Keep pass JSON as a plain array built from your DTO, then hand it to the generator. Don't let the library's object model leak into controllers — you want pass.json shape to be reviewable in a diff.
Testing. Golden-file tests are the only reliable ones: generate a pass with fixed inputs, unzip it, assert manifest.json hashes match each file, and verify the signature with OpenSSL rather than trusting the library:
openssl smime -verify -in signature -inform DER \
-content manifest.json -noverify
Add a second test that shells out to unzip -l and asserts the archive contains exactly pass.json, icon.png, [email protected], manifest.json, signature — no __MACOSX, no .DS_Store, no nested folder.
If you port the signing yourself (likely, given the library's age): build the zip with ZipArchive with all files at the archive root, compute SHA-1 of each file into manifest.json, write the manifest, then produce a detached PKCS#7 signature over manifest.json using openssl_pkcs7_sign with the WWDR intermediate included as an extra cert, output as DER (not PEM, not base64-armored).
symfony/* constraints conflict. Requiring the sibling library directly is easier but still dormant. Budget time for a fork or a vendored copy — and never resolve it with --ignore-platform-reqs in production.manifest.json must list every file except manifest.json and signature. Forgetting an asset, or including the manifest itself, produces a pass that installs on the simulator but is rejected on device with no useful error.openssl_pkcs7_sign omits the intermediate unless you pass the extra-certs file. Without the WWDR G4 in the chain, iOS refuses the pass — again, silently.ZipArchive::addFile preserves paths inconsistently; always addFromString with a bare filename.icon.png is mandatory, logo.png is not. Apple's docs bury this; a pass without an icon fails to display.formatVersion must be the number 1, not the string "1"..p12 or WWDR chain does not throw at generation time in a way that alerts you — passes just stop installing or stop updating. Add a scheduled command that parses the cert's validTo daily and alerts at 60/30/7 days. This is the single highest-value piece of operational code you'll write around this package.output_path config is a footgun. It writes generated passes to disk by default. In Laravel, prefer generating to memory and streaming, or to a temp path you clean up in a finally..pkpass manually, diff pass.json against expectations, re-verify manifest.json hashes with sha1sum, then verify the signature with the OpenSSL command above. If all three pass and the device still rejects it, it's the certificate chain.WalletPassGenerator interface, (2) the passes table with serial numbers, (3) an WalletPassUpdater interface you stub now and implement later. Those three decisions cost almost nothing today and make the difference between a prototype you can grow and a rewrite.services.xml and the library's signing code, port the ~200 meaningful lines into a Laravel-native service, and own it. Given 10 stars, zero dependents, and no release since January 2018, you are the maintainer the moment it enters your composer.json.How can I help you explore Laravel packages today?