naif/laravel-paypal
A Laravel wrapper for the PayPal REST API that makes it easy to create payments, redirect users for approval, and execute transactions. Clean, fluent methods, configurable credentials, and simple setup let you add PayPal checkout to any Laravel app quickly.
Install and wire up
composer require naif/laravel-paypal
php artisan vendor:publish --provider="Naif\PayPal\PayPalServiceProvider"
The provider/facade are auto-discovered on Laravel 5.5+. On older apps, register Naif\PayPal\PayPalServiceProvider in config/app.php and alias PayPal to Naif\PayPal\Facades\PayPal. Publishing drops a config/paypal.php — that file is your first stop: it defines the client ID, client secret, and the sandbox/live toggle. Map those to env vars (PAYPAL_CLIENT_ID, PAYPAL_SECRET, PAYPAL_MODE) and never commit the published values.
Where to look first
config/paypal.php — confirm the exact key names and the mode switch before writing any code.DateTime, strings, or Carbon.First use case: a balance widget on an admin dashboard
Create a sandbox REST app at developer.paypal.com, set the three env vars, then prove the credentials work:
php artisan tinker
>>> Naif\PayPal\Facades\PayPal::getBalance();
The balance response is a list of balances entries (one per currency) inside a wrapper with account_id, as_of_time, etc. Once you see a currency-code entry come back, you have a working integration and can move on to transactions:
$transactions = PayPal::getTransactions('2024-01-01T00:00:00Z', '2024-01-31T23:59:59Z');
Expect transaction_details with fields like transaction_info.transaction_amount, transaction_event_code, transaction_status, and payer_info. This package will not do checkout, refunds, subscriptions, or webhooks — if you need those, you want the official SDK or srmklive/laravel-paypal. Treat this one strictly as a reporting reader.
Wrap it in your own service, not the facade. The facade is fine for a prototype, but bind the client behind an interface so you can mock it in tests and swap libraries later if the package goes unmaintained:
interface PayPalReporter {
public function balance(string $currency = 'USD'): array;
public function transactions(Carbon $from, Carbon $to): Collection;
}
Cache the balance aggressively. Balance is a "current state" call — there is no reason to hit PayPal on every page load. Cache::remember('paypal.balance.usd', now()->addMinutes(5), fn () => ...) removes almost all rate-limit pain for a dashboard widget.
Sync transactions into your own table on a schedule. The reporting endpoints are read-only and paginated; a nightly job that pulls a bounded window and upserts by transaction_id (a.k.a. transaction_info.transaction_id) gives you a queryable local ledger, fast dashboards, and a safety net when PayPal returns a 5xx:
// app/Console/Commands/SyncPayPalTransactions.php
$window = 31; // keep well inside PayPal's max range
$from = now()->subDays($window)->startOfDay();
$to = now()->endOfDay();
$page = 1;
do {
$res = $this->reporter->transactions($from, $to, $page);
Transaction::upsert($this->map($res['transaction_details']), ['paypal_transaction_id']);
$page++;
} while ($page <= ($res['total_pages'] ?? 1));
Reconciliation is the killer app here. Pull transactions for the period, match transaction_info.transaction_amount.value against your local orders table by invoice/custom field, and surface a daily "unmatched" list. This is exactly what a small reporting wrapper buys you over hand-rolling HTTP calls.
Segment by date window. PayPal's transaction search rejects ranges beyond roughly 31 days (confirm in the docs; limits have moved before). Chunk long backfills into per-month loops and sleep a second between calls on bulk jobs.
Multi-account support via runtime config. For agencies managing several PayPal accounts, build a small factory that calls config()->set('paypal.client_id', ...) / config()->set('paypal.secret', ...) before resolving a fresh client instance — but only if the client reads config lazily. If it caches credentials on construction, use app()->forgetInstance(...) or construct the client directly with an array of credentials.
Test against sandbox, always. The mode toggle is a one-liner, but sandbox accounts start empty — seed them with the sandbox "Create Account" tools and a few test transactions, or your first getTransactions() call returns {"transaction_details": [], "total_items": 0} and looks broken when it isn't.
total_items and balances rather than assuming success from "no exception".T and Z (2024-01-01T00:00:00Z). Plain 2024-01-01 strings usually earn a 400 INVALID_REQUEST. Also verify whether the package converts Carbon for you before you do it twice.page_size is small (often 100); anything beyond a quiet day will silently truncate your sync if you only read page 1. Always loop on total_pages.expires_in.api.paypal.com); 429 → back off exponentially and cache. Don't retry 401s.balances and match currency_code; don't blindly take [0] — a multi-currency account will get EUR on a good day.debug_id from the body — that ID is what PayPal support asks for. Never log the client secret or full token.srmklive/laravel-paypal in mind as the actively maintained fallback if you later need payments beyond reporting.createOrder, no capture, no IPN/webhook handling. If a colleague asks why checkouts fail after installing this, that's the answer.How can I help you explore Laravel packages today?