miracode/stripe-bundle
A Laravel package that bundles Stripe integration essentials, helping you add payments, webhooks, and billing to your app with less boilerplate. A convenient starting point for Stripe in Laravel projects.
This is a Symfony bundle, not a Laravel package. There is no service provider, no config/stripe.php, and no Cashier-style Eloquent models. The entire value proposition is "register stripe/stripe-php in the Symfony DI container and read your keys from config." If you are in a Laravel codebase, the correct day-one move is composer require stripe/stripe-php directly and bind your own singleton — but reading this bundle is still worthwhile as a reference implementation, and the patterns below map cleanly.
Check constraints before require. Open the bundle's composer.json on GitHub first. Confirm:
symfony/* range (a ^4.4|^5.0 constraint will fail on Symfony 6/7 apps),php range,stripe/stripe-php (wraps the SDK) or ships its own HTTP client (rots fast against Stripe's dated API versions).composer require miracode/stripe-bundle
Register the bundle. Flex usually does this automatically into config/bundles.php:
return [
// ...
Miracode\StripeBundle\MiracodeStripeBundle::class => ['all' => true],
];
Older/non-Flex setups need this added manually — if the bundle "does nothing," check here first.
Read the README for the exact config root key (miracode_stripe, stripe, miracode_stripe_bundle, …) and the service ID. Do not guess either. One wrong root key yields a silent no-op or a container compile error with a confusing message.
Keys into .env.local, never .env:
STRIPE_PUBLIC_KEY=pk_test_...
STRIPE_SECRET_KEY=sk_test_...
STRIPE_WEBHOOK_SECRET=whsec_...
bin/console cache:clear
Symfony caches the compiled container aggressively. After any bundle config change, a stale container will keep serving the old values and you will burn an hour debugging a config that is already correct.
DependencyInjection/ — the extension class and Configuration.php. This is the whole bundle. Ten minutes of reading tells you every config key and every service it registers.
Service discovery:
bin/console debug:config miracode_stripe # resolved config values
bin/console debug:container --show-private | grep -i stripe
bin/console debug:autowiring stripe
debug:autowiring is the fastest way to find the alias you can type-hint.
Inject the client into a controller and create a Checkout Session — the smallest end-to-end loop that proves keys, container wiring, and network egress all work:
#[Route('/checkout/{order}', name: 'checkout', methods: ['POST'])]
public function checkout(Order $order, \Stripe\StripeClient $stripe): Response
{
$session = $stripe->checkout->sessions->create([
'mode' => 'payment',
'line_items' => [[
'price_data' => [
'currency' => 'usd',
'unit_amount' => $order->getTotalInCents(),
'product_data' => ['name' => $order->getDescription()],
],
'quantity' => 1,
]],
'success_url' => $this->generateUrl('checkout_success', [], UrlGeneratorInterface::ABSOLUTE_URL)
. '?session_id={CHECKOUT_SESSION_ID}',
'cancel_url' => $this->generateUrl('cart', [], UrlGeneratorInterface::ABSOLUTE_URL),
'client_reference_id' => (string) $order->getId(),
], ['idempotency_key' => 'order-'.$order->getId()]);
return $this->redirect($session->url);
}
If debug:autowiring doesn't show Stripe\StripeClient, the bundle exposes a different concrete class or a wrapper — use whatever it registers. Do not instantiate new StripeClient(...) yourself; that defeats the point of the bundle and splits key management in two places.
Constructor injection into a domain service is the pattern the bundle is built for:
final class StripePaymentGateway
{
public function __construct(private readonly \Stripe\StripeClient $stripe) {}
}
Avoid $container->get('stripe.client') in application code. It hides the dependency, breaks static analysis, and makes the eventual swap to a first-party adapter a grep-and-pray exercise.
This is the single highest-leverage pattern for a 9-star, 0-dependent dependency. The bundle should never appear in a controller signature after week one:
interface PaymentGateway
{
public function charge(Order $order): PaymentResult;
public function refund(string $paymentIntentId, ?int $amount = null): void;
}
# config/services.yaml
App\Payment\PaymentGateway:
alias: App\Payment\StripePaymentGateway
Now the bundle is an implementation detail. When you fork it, pin it, or replace it with Cashier/stripe-php, exactly one class changes.
Most thin wrappers expose a mode / sandbox / test flag and derive the API base or accept two key pairs. Two workable patterns:
.env.local / Symfony secrets — simplest, least surprising.sandbox boolean plus paired keys — check the README, because if the bundle builds the client from a single secret key you do not want to fight it.Assert at boot that the key prefix matches the environment:
// kernel boot or a compiler pass
if ($this->environment === 'prod' && !str_starts_with($_ENV['STRIPE_SECRET_KEY'], 'sk_live_')) {
throw new \RuntimeException('Non-live Stripe key in production.');
}
This is the cheapest possible guard against the most expensive possible mistake.
The description claims "extensible foundation for webhooks"; treat that as "it does not do them for you." Build the endpoint yourself:
#[Route('/stripe/webhook', methods: ['POST'])]
public function webhook(Request $request, WebhookService $webhooks): Response
{
$payload = $request->getContent();
$sig = $request->headers->get('Stripe-Signature', '');
try {
$event = \Stripe\Webhook::constructEvent($payload, $sig, $_ENV['STRIPE_WEBHOOK_SECRET']);
} catch (\Stripe\Exception\SignatureVerificationException) {
return new Response('', 400);
}
$webhooks->handle($event); // dedupe on $event->id, then dispatch to a message bus
return new Response('', 200);
}
Non-negotiables inside handle():
$event->id — a UNIQUE index on a processed_stripe_events table. Stripe retries; without this you double-provision or double-ship.Because the bundle only gives you the client, any "the webhook never arrived" recovery is yours. A scheduled command that lists recent PaymentIntents and reconciles against local orders covers you for dropped webhooks and for replaying a bad deploy:
$intents = $stripe->paymentIntents->all([
'created' => ['gte' => $since->getTimestamp()],
'limit' => 100,
]);
Point the bundle's config at test keys in config/packages/test/. For unit tests, mock the interface (PaymentGateway), never the StripeClient — the SDK's method chains are built from magic __call proxies and are miserable to mock. For integration tests against real Stripe, use sk_test_ plus Stripe test clocks and test card numbers.
Laravel-side translation: everything above is AppServiceProvider::register() + a config/stripe.php + the same PaymentGateway interface. Cashier replaces the interface entirely if your billing model is conventional subscriptions.
It is not a Laravel package. No StripeServiceProvider, no facade, no publishable config. Adding it to a Laravel app means pulling in a bundle whose only value — Symfony DI wiring — registers to a container Laravel does not have. Do not try to bridge this; write a 30-line service provider around stripe/stripe-php.
Symfony constraint collision is the most likely failure at install time. Laravel 11/12 pin symfony/* 6/7. A bundle declaring ^4.4|^5.0 hard-fails composer update or triggers a partial-update cascade. Check composer.json on GitHub before requiring, not after.
Stale container after config edits. bin/console cache:clear after every bundle config change, especially in dev. A cached container will happily serve old keys and send you hunting ghosts.
Wrong config root key = silent no-op. If keys aren't loading, run bin/console debug:config <root> and confirm the values are actually merged. If the tree name is wrong, Symfony ignores your YAML without complaint.
Stripe API version drift. The SDK is pinned; Stripe ships dated API versions with deprecation windows. Budget a recurring calendar reminder to bump stripe/stripe-php and read the changelog. This bundle gives no evidence anyone is tracking that for you.
Money is in minor units, always integers. unit_amount: 10.00 is invalid or is ten cents depending on the endpoint. Keep a single Money value object with getAmountInCents() and never do float arithmetic on prices.
Always pass an idempotency key on POSTs — ['idempotency_key' => "..."] as the second argument. Retries, double-clicks, and webhook-driven re-attempts are otherwise duplicate charges.
Do not build card forms that post to your server. Use Stripe Checkout or Elements. If 4242… or a PAN ever touches your application, you've jumped from SAQ A into a real PCI scope. That's a compliance boundary, not a style preference.
Extension points are the container itself. There are no events or plugin hooks you should count on. Override by decoration:
App\Payment\InstrumentedStripeClient:
decorates: <the bundle's service id>
arguments: ['@.inner', '@logger']
That's also how you'd add logging, metrics, or a timeout wrapper around outbound calls — which the bundle does not provide, and which you need.
Vendor and pin. 9 stars, 0 dependents, single maintainer. Copy the source into your repo (or a private fork), pin the SDK, name an internal owner, and set a review date on the risk register. An unowned fork of an unowned package compounds.
Instrument the failure modes you will actually hit: Composer resolution failure in CI (not prod), webhook 4xx rate after a secret rotation, duplicate-event processing without a dedupe table, and non-live keys in prod. Every one of these is silent by default.
Opportunity score ≠ undervalued asset. A high "opportunity" sub-score with a low composite means "unclaimed niche," which usually means nobody needed it badly enough to maintain it. Read it as a reference implementation, adopt it as a dependency only for low-revenue Symfony surfaces with a hard timebox.
How can I help you explore Laravel packages today?