jeffersongoncalves/laravel-paddle
Laravel Paddle provides a clean integration for Paddle billing in Laravel applications, simplifying subscriptions, payments, checkout flows, webhooks, and API configuration through familiar Laravel conventions.
Install and configure.
composer require <vendor>/paddle-billing # PHP 8.2+, Laravel 12/13
php artisan vendor:publish --tag=paddle-config
Set the env vars the package's config file reads — typically PADDLE_API_KEY, PADDLE_WEBHOOK_SECRET, PADDLE_SANDBOX, plus a client-side token if you use Paddle.js. Run php artisan config:cache afterwards.
Look here first, in this order:
config/paddle.php — the only place that knows about sandbox vs. production; read it before writing code.PaddleException — your entire error-handling strategy hangs off its error body + machine-readable error code.First use case: a server-side checkout. Resolve a price_id you control, create a transaction via the transactions resource with the customer and price, then hand the returned transaction.id to Paddle.js Checkout on the front end. Do not post prices from the browser. Getting this loop working end-to-end (create transaction → complete in sandbox → receive webhook) is the fastest way to validate your config, keys, and webhook secret in one pass.
Wrap the client, don't sprinkle it. The package is a thin API wrapper. Put it behind your own actions/services (CreateSubscriptionCheckout, SyncSubscriptionFromWebhook) so that resource calls, DTO mapping to your Eloquent models, and error translation live in one layer. Controllers and jobs then call your actions, and you can swap or fake the transport later.
Mirror state locally, driven by webhooks. Keep customers, subscriptions, and transactions tables keyed by Paddle IDs (ctm_, sub_, txn_). Treat webhooks as the source of truth for state transitions and the API as a source of truth for reads you don't have locally. Don't poll subscriptions()->get() on page load to decide whether a user has access.
Webhook pipeline. Route it in api.php (or otherwise exclude CSRF), verify the signature before decoding, persist the raw payload to a webhook_events table with a unique index on the event ID, dispatch a queued job, and return 200 immediately. Idempotency on the event ID is non-negotiable — Paddle will retry.
Handle events by type, not by shape. subscription.created, subscription.updated, subscription.canceled, transaction.completed, adjustment.created (refunds/credit notes) each map to a small handler. Keep a default branch that logs and returns 200 so new event types don't blow up your endpoint.
Errors as a first-class path.
try {
$transaction = $paddle->transactions()->create($payload);
} catch (PaddleException $e) {
Log::warning('paddle.error', ['code' => $e->getCode(), 'body' => $e->getBody()]);
// map known codes to user-facing messages, rethrow the rest
}
Branch on the machine-readable code, never on the message string. Retry only on 5xx/429 with backoff; a 4xx means your payload is wrong.
Testing. Because the package almost certainly rides on Laravel's HTTP client, Http::fake() around the resource calls is usually enough to assert the exact request body and to return canned Paddle responses. Bind a fake client in the container for unit tests; hit the sandbox only in a small, tagged integration suite.
$request->getContent(). Any middleware that re-encodes JSON, or code that verifies against $request->all(), will fail intermittently and confusingly. Verify before any parsing.occurred_at (or a monotonic local state machine) before overwriting a subscription status. A late subscription.updated must not resurrect a cancelled subscription.request_id is your support ticket. Paddle's error body carries one — log it alongside the code and body, and include it in exception reports.composer.json since a 1.0.x API surface can still shift.How can I help you explore Laravel packages today?