Laravel Cashier (Stripe)

업데이트됨

번역일: 2026년 7월 28일

이 페이지는 원문이 업데이트되어 번역이 갱신되었습니다.

원문 수정
2026년 7월 28일
번역 갱신
2026년 7월 28일

Laravel Cashier (Stripe)

결제(Cashier)

소개

Laravel Cashier StripeStripe의 구독 결제 서비스를 간결하고 유창하게 다룰 수 있는 인터페이스를 제공합니다. 구독 결제에 필요한 반복적이고 번거로운 코드 대부분을 Cashier가 대신 처리해 줍니다. 기본적인 구독 관리 외에도, 쿠폰 적용, 구독 플랜 변경, 구독 수량(quantity) 관리, 해지 유예 기간(grace period) 설정, 인보이스 PDF 생성 등의 기능도 지원합니다.

결제 (Cashier)

Cashier 업그레이드

새로운 버전의 Cashier로 업그레이드할 때는 반드시 업그레이드 가이드를 꼼꼼히 검토하시기 바랍니다.

WARNING

Cashier는 호환성 문제를 방지하기 위해 Stripe API 버전을 고정하여 사용합니다. Cashier 16은 Stripe API 버전 2025-06-30.basil을 사용합니다. Stripe API 버전은 새로운 Stripe 기능 및 개선 사항을 활용하기 위해 마이너 릴리즈 시점에 업데이트될 수 있습니다.

결제(Cashier)

설치

먼저 Composer 패키지 매니저를 사용해 Stripe용 Cashier 패키지를 설치합니다:

composer require laravel/cashier

패키지 설치가 완료되면 vendor:publish Artisan 명령어로 Cashier의 마이그레이션 파일을 퍼블리시합니다:

php artisan vendor:publish --tag="cashier-migrations"

그런 다음 데이터베이스 마이그레이션을 실행합니다:

php artisan migrate

마이그레이션이 실행되면 users 테이블에 여러 컬럼이 추가됩니다. 또한 고객의 구독 정보를 저장하는 subscriptions 테이블과, 여러 가격이 포함된 구독을 위한 subscription_items 테이블이 새로 생성됩니다.

필요하다면 아래 명령어로 Cashier의 설정 파일도 퍼블리시할 수 있습니다:

php artisan vendor:publish --tag="cashier-config"

마지막으로, Stripe에서 발생하는 모든 이벤트를 Cashier가 올바르게 처리할 수 있도록 Cashier 웹훅 설정을 반드시 완료하세요.

WARNING

Stripe는 Stripe 식별자를 저장하는 컬럼에 대소문자를 구분하는 콜레이션(case-sensitive collation) 사용을 권장합니다. MySQL을 사용하는 경우 stripe_id 컬럼의 콜레이션을 utf8_bin으로 설정해야 합니다. 자세한 내용은 Stripe 공식 문서를 참고하세요.

설정

Billable 모델

Cashier를 사용하기 전에, 결제 대상이 되는 모델에 Billable 트레이트를 추가해야 합니다. 일반적으로 App\Models\User 모델에 추가합니다. 이 트레이트는 구독 생성, 쿠폰 적용, 결제 수단 업데이트 등 자주 사용하는 결제 관련 기능을 메서드로 제공합니다:

use Laravel\Cashier\Billable; class User extends Authenticatable { use Billable; }

Cashier는 기본적으로 Laravel이 제공하는 App\Models\User 클래스를 결제 모델로 사용합니다. 다른 모델을 사용하고 싶다면 useCustomerModel 메서드로 변경할 수 있습니다. 이 메서드는 보통 AppServiceProviderboot 메서드에서 호출합니다:

use App\Models\Cashier\User; use Laravel\Cashier\Cashier; /** * 애플리케이션 서비스를 부트스트랩합니다. */ public function boot(): void { Cashier::useCustomerModel(User::class); }

WARNING

App\Models\User 이외의 모델을 사용하는 경우, Cashier 마이그레이션을 퍼블리시한 뒤 해당 모델의 테이블 이름에 맞게 수정해야 합니다.

API 키 설정

다음으로, 애플리케이션의 .env 파일에 Stripe API 키를 설정합니다. API 키는 Stripe 대시보드에서 확인할 수 있습니다:

STRIPE_KEY=your-stripe-key STRIPE_SECRET=your-stripe-secret STRIPE_WEBHOOK_SECRET=your-stripe-webhook-secret

WARNING

STRIPE_WEBHOOK_SECRET 환경 변수는 반드시 설정해야 합니다. 이 값은 수신된 웹훅 요청이 실제로 Stripe에서 전송된 것인지 검증하는 데 사용됩니다.

통화 설정

Cashier의 기본 통화는 미국 달러(USD)입니다. .env 파일에서 CASHIER_CURRENCY 환경 변수를 설정하여 기본 통화를 변경할 수 있습니다. 예를 들어 원화(KRW)를 사용하려면 다음과 같이 설정합니다:

CASHIER_CURRENCY=krw

통화 외에도, 인보이스에 금액을 표시할 때 사용할 로케일을 지정할 수 있습니다. Cashier는 내부적으로 PHP의 NumberFormatter 클래스를 통해 통화 형식을 적용합니다:

CASHIER_CURRENCY_LOCALE=ko_KR

WARNING

en 이외의 로케일을 사용하려면 서버에 ext-intl PHP 확장 모듈이 설치되어 있어야 합니다.

세금 설정

Stripe Tax를 활용하면 Stripe가 생성하는 모든 인보이스에 대해 세금을 자동으로 계산할 수 있습니다. AppServiceProviderboot 메서드에서 calculateTaxes 메서드를 호출하여 자동 세금 계산을 활성화합니다:

use Laravel\Cashier\Cashier; /** * 애플리케이션 서비스를 부트스트랩합니다. */ public function boot(): void { Cashier::calculateTaxes(); }

자동 세금 계산이 활성화되면 이후 생성되는 모든 신규 구독과 단건 인보이스에 세금이 자동으로 적용됩니다.

이 기능이 정상적으로 동작하려면 고객의 이름, 주소, 세금 ID 등 청구 정보가 Stripe에 동기화되어 있어야 합니다. Cashier에서 제공하는 고객 데이터 동기화세금 ID 관련 메서드를 활용하면 됩니다.

로깅

Cashier는 심각한 Stripe 오류를 기록할 때 사용할 로그 채널을 지정할 수 있습니다. .env 파일에서 CASHIER_LOGGER 환경 변수를 설정하면 됩니다:

CASHIER_LOGGER=stack

Stripe API 호출 중 발생한 예외는 애플리케이션의 기본 로그 채널을 통해 기록됩니다.

커스텀 모델 사용

Cashier가 내부적으로 사용하는 모델을 직접 정의하고 확장할 수 있습니다. 해당 Cashier 모델을 상속한 커스텀 모델을 만들면 됩니다:

use Laravel\Cashier\Subscription as CashierSubscription; class Subscription extends CashierSubscription { // ... }

커스텀 모델을 정의한 후에는 AppServiceProviderboot 메서드에서 Cashier에 해당 모델을 사용하도록 지정합니다:

use App\Models\Cashier\Subscription; use App\Models\Cashier\SubscriptionItem; /** * 애플리케이션 서비스를 부트스트랩합니다. */ public function boot(): void { Cashier::useSubscriptionModel(Subscription::class); Cashier::useSubscriptionItemModel(SubscriptionItem::class); }

결제(Cashier)

빠른 시작

일반 상품 판매

NOTE

Stripe Checkout을 사용하기 전에, Stripe 대시보드에서 고정 가격이 설정된 상품(Product)을 먼저 등록해야 합니다. 또한 Cashier 웹훅 처리도 반드시 설정하세요.

애플리케이션에 결제 기능을 직접 구현하는 것은 쉽지 않지만, Cashier와 Stripe Checkout을 함께 사용하면 현대적이고 안정적인 결제 시스템을 빠르게 구축할 수 있습니다.

단건 결제(구독이 아닌 일반 상품 판매)를 처리할 때는 Cashier의 checkout 메서드를 사용해 고객을 Stripe Checkout 페이지로 이동시킵니다. 고객이 결제를 완료하면 지정한 성공 URL로 리디렉션됩니다.

use Illuminate\Http\Request; Route::get('/checkout', function (Request $request) { $stripePriceId = 'price_deluxe_album'; $quantity = 1; return $request->user()->checkout([$stripePriceId => $quantity], [ 'success_url' => route('checkout-success'), 'cancel_url' => route('checkout-cancel'), ]); })->name('checkout'); Route::view('/checkout/success', 'checkout.success')->name('checkout-success'); Route::view('/checkout/cancel', 'checkout.cancel')->name('checkout-cancel');

위 예시에서 checkout 메서드에 전달하는 price_deluxe_album은 Stripe 대시보드에 등록한 "가격 식별자(price identifier)"입니다. Stripe에서 "가격(price)"이란 특정 상품에 연결된 가격 정보를 의미합니다.

checkout 메서드는 필요한 경우 Stripe에 고객 레코드를 자동으로 생성하고, 이를 애플리케이션 데이터베이스의 사용자와 연결합니다. 결제가 완료되거나 취소되면 고객은 지정한 성공 또는 취소 페이지로 이동합니다.

Stripe Checkout에 메타데이터 전달하기

상품을 판매할 때는 완료된 주문과 구매한 상품을 Cart(장바구니)나 Order(주문) 모델로 관리하는 것이 일반적입니다. 고객을 Stripe Checkout으로 이동시킬 때 기존 주문 식별자를 함께 전달해두면, 결제 완료 후 돌아왔을 때 해당 주문과 결제를 쉽게 연결할 수 있습니다.

이를 위해 checkout 메서드에 metadata 배열을 전달합니다. 아래 예시는 고객이 결제를 시작할 때 미완료 상태의 Order를 먼저 생성하는 흐름을 보여줍니다. 여기서 CartOrder 모델은 설명을 위한 예시이며, Cashier가 제공하는 것이 아닙니다. 실제 구현은 프로젝트 요구사항에 맞게 자유롭게 작성하면 됩니다.

use App\Models\Cart; use App\Models\Order; use Illuminate\Http\Request; Route::get('/cart/{cart}/checkout', function (Request $request, Cart $cart) { $order = Order::create([ 'cart_id' => $cart->id, 'price_ids' => $cart->price_ids, 'status' => 'incomplete', ]); return $request->user()->checkout($order->price_ids, [ 'success_url' => route('checkout-success').'?session_id={CHECKOUT_SESSION_ID}', 'cancel_url' => route('checkout-cancel'), 'metadata' => ['order_id' => $order->id], ]); })->name('checkout');

success_url에 포함된 {CHECKOUT_SESSION_ID}는 Stripe가 자동으로 실제 세션 ID로 치환해주는 템플릿 변수입니다. 결제 완료 후 고객이 성공 페이지로 돌아오면, 이 세션 ID를 사용해 Stripe에서 결제 정보와 메타데이터를 조회할 수 있습니다.

다음은 결제 성공 라우트의 구현 예시입니다. 이 라우트에서 세션 ID로 Stripe Checkout 세션을 조회하고, 메타데이터에 저장해둔 주문 ID를 꺼내 주문 상태를 업데이트합니다.

use App\Models\Order; use Illuminate\Http\Request; use Laravel\Cashier\Cashier; Route::get('/checkout/success', function (Request $request) { $sessionId = $request->get('session_id'); if ($sessionId === null) { return; } $session = Cashier::stripe()->checkout->sessions->retrieve($sessionId); if ($session->payment_status !== 'paid') { return; } $orderId = $session['metadata']['order_id'] ?? null; $order = Order::findOrFail($orderId); $order->update(['status' => 'completed']); return view('checkout-success', ['order' => $order]); })->name('checkout-success');

Checkout 세션 객체에 포함된 전체 데이터 구조는 Stripe 공식 문서를 참고하세요.

구독 판매

NOTE

Stripe Checkout을 사용하기 전에, Stripe 대시보드에서 고정 가격이 설정된 상품(Product)을 먼저 등록해야 합니다. 또한 Cashier 웹훅 처리도 반드시 설정하세요.

구독 결제도 Cashier와 Stripe Checkout을 활용하면 어렵지 않게 구현할 수 있습니다.

예를 들어, 월간 플랜(price_basic_monthly)과 연간 플랜(price_basic_yearly)으로 구성된 "Basic" 구독 상품(pro_basic)을 Stripe 대시보드에서 등록했다고 가정해 봅시다. 추가로 전문가용 플랜인 pro_expert도 있다고 가정합니다.

고객이 요금제 페이지에서 "Basic 플랜 구독하기" 버튼을 클릭하면, 아래 라우트로 이동해 Stripe Checkout 세션이 시작됩니다.

use Illuminate\Http\Request; Route::get('/subscription-checkout', function (Request $request) { return $request->user() ->newSubscription('default', 'price_basic_monthly') ->trialDays(5) ->allowPromotionCodes() ->checkout([ 'success_url' => route('your-success-route'), 'cancel_url' => route('your-cancel-route'), ]); });

결제가 완료되거나 취소되면 checkout 메서드에 전달한 URL로 고객이 리디렉션됩니다. 일부 결제 수단은 처리에 몇 초가 걸릴 수 있으므로, 구독이 실제로 활성화되는 시점을 정확히 파악하려면 Cashier 웹훅 처리를 반드시 설정해야 합니다.

구독 기능을 구현했다면, 구독한 사용자만 특정 기능에 접근할 수 있도록 제한해야 합니다. Cashier의 Billable 트레이트가 제공하는 subscribed 메서드로 현재 구독 상태를 확인할 수 있습니다.

@if ($user->subscribed()) <p>현재 구독 중입니다.</p> @endif

특정 상품이나 가격 플랜 구독 여부도 쉽게 확인할 수 있습니다.

@if ($user->subscribedToProduct('pro_basic')) <p>Basic 상품을 구독 중입니다.</p> @endif @if ($user->subscribedToPrice('price_basic_monthly')) <p>Basic 월간 플랜을 구독 중입니다.</p> @endif

구독 확인 미들웨어 만들기

구독 여부를 확인하는 로직을 미들웨어로 분리해두면 라우트에 간편하게 적용할 수 있습니다.

<?php namespace App\Http\Middleware; use Closure; use Illuminate\Http\Request; use Symfony\Component\HttpFoundation\Response; class Subscribed { /** * 요청을 처리합니다. */ public function handle(Request $request, Closure $next): Response { if (! $request->user()?->subscribed()) { // 구독하지 않은 사용자를 결제 페이지로 이동시킵니다. return redirect('/billing'); } return $next($request); } }

미들웨어를 정의한 후에는 라우트에 적용합니다.

use App\Http\Middleware\Subscribed; Route::get('/dashboard', function () { // ... })->middleware([Subscribed::class]);

고객이 구독 플랜을 직접 관리할 수 있게 하기

고객이 구독 플랜을 변경하거나 결제 수단을 업데이트하고 싶을 수 있습니다. 이를 가장 쉽게 제공하는 방법은 Stripe의 고객 청구 포털(Customer Billing Portal)로 이동시키는 것입니다. 이 포털에서는 청구서 다운로드, 결제 수단 변경, 구독 플랜 변경이 모두 가능합니다.

먼저 애플리케이션에 청구 포털로 이동하는 링크를 추가합니다.

<a href="{{ route('billing') }}"> 결제 관리 </a>

다음으로, Stripe 고객 청구 포털 세션을 시작하고 사용자를 포털로 리디렉션하는 라우트를 정의합니다. redirectToBillingPortal 메서드에는 포털에서 나왔을 때 돌아올 URL을 전달합니다.

use Illuminate\Http\Request; Route::get('/billing', function (Request $request) { return $request->user()->redirectToBillingPortal(route('dashboard')); })->middleware(['auth'])->name('billing');

NOTE

Cashier 웹훅 처리를 설정해 두면, Stripe에서 전송하는 웹훅을 수신해 애플리케이션의 데이터베이스를 자동으로 최신 상태로 유지합니다. 예를 들어 고객이 Stripe 청구 포털에서 구독을 취소하면, Cashier가 해당 웹훅을 수신하고 데이터베이스의 구독 상태를 "취소됨"으로 자동 업데이트합니다.

결제(Cashier)

고객(Customers)

고객 조회

Stripe ID를 통해 결제 가능한 고객을 조회하려면 Cashier::findBillable 메서드를 사용하세요. 이 메서드는 billable 모델 인스턴스를 반환합니다:

use Laravel\Cashier\Cashier; $user = Cashier::findBillable($stripeId);

고객 생성

구독을 시작하지 않고 Stripe 고객만 먼저 생성하고 싶은 경우, createAsStripeCustomer 메서드를 사용할 수 있습니다:

$stripeCustomer = $user->createAsStripeCustomer();

Stripe에 고객이 생성된 후에는 나중에 구독을 시작할 수 있습니다. Stripe API에서 지원하는 고객 생성 파라미터$options 배열로 전달할 수도 있습니다:

$stripeCustomer = $user->createAsStripeCustomer($options);

billable 모델에 해당하는 Stripe 고객 객체를 가져오려면 asStripeCustomer 메서드를 사용하세요:

$stripeCustomer = $user->asStripeCustomer();

해당 billable 모델이 이미 Stripe 고객으로 등록되어 있는지 확실하지 않은 경우에는 createOrGetStripeCustomer 메서드를 사용하세요. Stripe에 해당 고객이 없으면 새로 생성하고, 이미 있으면 기존 고객 객체를 반환합니다:

$stripeCustomer = $user->createOrGetStripeCustomer();

고객 정보 수정

Stripe 고객 정보를 직접 업데이트해야 하는 경우, updateStripeCustomer 메서드를 사용하세요. Stripe API에서 지원하는 고객 업데이트 옵션 배열을 인수로 전달합니다:

$stripeCustomer = $user->updateStripeCustomer($options);

잔액(Balances)

Stripe에서는 고객의 "잔액"을 증가(credit)하거나 감소(debit)시킬 수 있습니다. 이 잔액은 이후 새로운 인보이스에 자동으로 반영됩니다. 고객의 현재 잔액은 billable 모델의 balance 메서드로 확인할 수 있으며, 해당 통화에 맞는 형식의 문자열로 반환됩니다:

$balance = $user->balance();

고객 잔액을 증가시키려면 creditBalance 메서드에 금액을 전달하세요. 선택적으로 설명도 추가할 수 있습니다:

$user->creditBalance(500, '프리미엄 고객 잔액 충전');

고객 잔액을 감소시키려면 debitBalance 메서드를 사용하세요:

$user->debitBalance(300, '이용 정책 위반 패널티');

applyBalance 메서드는 고객에 대한 새로운 잔액 트랜잭션을 생성합니다. balanceTransactions 메서드로 트랜잭션 기록을 조회할 수 있으며, 고객에게 충전·차감 내역을 보여줄 때 유용합니다:

// 모든 트랜잭션 조회 $transactions = $user->balanceTransactions(); foreach ($transactions as $transaction) { // 트랜잭션 금액 $amount = $transaction->amount(); // 예: $2.31 // 관련 인보이스 조회 (있는 경우) $invoice = $transaction->invoice(); }

세금 ID(Tax IDs)

Cashier를 통해 고객의 세금 ID를 간편하게 관리할 수 있습니다. 예를 들어, taxIds 메서드를 사용하면 고객에게 등록된 모든 세금 ID를 컬렉션으로 조회할 수 있습니다:

$taxIds = $user->taxIds();

식별자를 통해 특정 세금 ID를 조회할 수도 있습니다:

$taxId = $user->findTaxId('txi_belgium');

새 세금 ID를 추가하려면 유효한 타입과 값을 createTaxId 메서드에 전달하세요:

$taxId = $user->createTaxId('eu_vat', 'BE0123456789');

createTaxId 메서드를 호출하면 VAT ID가 즉시 고객 계정에 추가됩니다. VAT ID 검증은 Stripe에서 비동기로 처리됩니다. 검증 결과를 받으려면 customer.tax_id.updated 웹훅 이벤트를 구독하고 VAT ID의 verification 파라미터를 확인하세요. 웹훅 처리에 대한 자세한 내용은 웹훅 핸들러 정의 문서를 참고하세요.

세금 ID를 삭제하려면 deleteTaxId 메서드를 사용하세요:

$user->deleteTaxId('txi_belgium');

Stripe와 고객 데이터 동기화

일반적으로 애플리케이션 사용자가 이름, 이메일, 기타 정보를 변경하면 Stripe에도 동일하게 반영해야 합니다. 그래야 Stripe에 저장된 정보와 애플리케이션 데이터가 일치하게 됩니다.

이를 자동화하려면 billable 모델의 updated 이벤트에 리스너를 등록하고, 리스너 안에서 syncStripeCustomerDetails 메서드를 호출하세요:

use App\Models\User; use function Illuminate\Events\queueable; /** * 모델 부트 메서드 */ protected static function booted(): void { static::updated(queueable(function (User $customer) { if ($customer->hasStripeId()) { $customer->syncStripeCustomerDetails(); } })); }

이제 고객 모델이 업데이트될 때마다 Stripe와 자동으로 동기화됩니다. 참고로 Cashier는 고객이 처음 생성될 때도 자동으로 Stripe와 정보를 동기화합니다.

Stripe로 동기화되는 컬럼을 커스터마이즈하려면 Cashier가 제공하는 여러 메서드를 오버라이드하면 됩니다. 예를 들어, stripeName 메서드를 오버라이드하면 Cashier가 Stripe에 동기화할 때 사용할 "이름" 속성을 변경할 수 있습니다:

/** * Stripe에 동기화할 고객 이름 반환 */ public function stripeName(): string|null { return $this->company_name; }

마찬가지로 stripeEmail, stripePhone(최대 20자), stripeAddress, stripePreferredLocales 메서드를 오버라이드할 수 있습니다. 이 메서드들은 Stripe 고객 객체 업데이트 시 각각에 대응하는 파라미터에 값을 동기화합니다. 동기화 프로세스 전체를 직접 제어하고 싶다면 syncStripeCustomerDetails 메서드 자체를 오버라이드하세요.

빌링 포털(Billing Portal)

Stripe는 고객이 구독, 결제 수단, 청구 내역을 직접 관리할 수 있는 빌링 포털을 제공합니다. 컨트롤러나 라우트에서 billable 모델의 redirectToBillingPortal 메서드를 호출하면 사용자를 빌링 포털로 리다이렉트할 수 있습니다:

use Illuminate\Http\Request; Route::get('/billing-portal', function (Request $request) { return $request->user()->redirectToBillingPortal(); });

기본적으로 사용자가 구독 관리를 마치면 Stripe 빌링 포털 내 링크를 통해 애플리케이션의 home 라우트로 돌아오게 됩니다. redirectToBillingPortal 메서드에 URL을 인수로 전달하면 돌아올 페이지를 직접 지정할 수 있습니다:

use Illuminate\Http\Request; Route::get('/billing-portal', function (Request $request) { return $request->user()->redirectToBillingPortal(route('billing')); });

HTTP 리다이렉트 응답 없이 빌링 포털 URL만 생성하려면 billingPortalUrl 메서드를 사용하세요:

$url = $request->user()->billingPortalUrl(route('billing'));

결제 수단 (Payment Methods)

결제 수단 저장

구독을 생성하거나 단건 결제를 처리하려면, 먼저 고객의 결제 정보를 안전하게 수집해야 합니다. 구체적인 방법은 결제 수단을 나중에 재사용할 목적으로 저장하는지, 아니면 즉시 단건 결제를 처리하는지에 따라 달라집니다. 아래에서 두 가지 경우를 모두 살펴봅니다.

Stripe의 Payment Element를 사용하면 카드, Apple Pay, Google Pay, iDEAL 등 다양한 결제 수단을 지원할 수 있습니다.

구독용 Payment Element

먼저 Setup Intent를 생성하고 뷰에 전달합니다.

return view('subscribe', [ 'intent' => $user->createSetupIntent() ]);

Setup Intent의 client_secret을 사용해 Payment Element를 마운트합니다.

<div id="payment-element"></div> <button id="submit">구독 시작</button> <script src="https://js.stripe.com/v3/"></script> <script> const stripe = Stripe('stripe-public-key'); const elements = stripe.elements({ clientSecret: '{{ $intent->client_secret }}' }); const paymentElement = elements.create('payment'); paymentElement.mount('#payment-element'); document.getElementById('submit').addEventListener('click', async () => { const { error } = await stripe.confirmSetup({ elements, confirmParams: { return_url: '{{ route("subscription.complete") }}', }, }); if (error) { // error.message를 사용자에게 표시... } }); </script>

Stripe가 return_url로 리다이렉트한 후, 쿼리 파라미터로 setup_intent ID를 전달합니다. 이 값으로 결제 수단을 조회하고 구독을 생성할 수 있습니다.

use Illuminate\Http\Request; Route::get('/subscription/complete', function (Request $request) { $setupIntent = $request->user()->findSetupIntent( $request->setup_intent ); $paymentMethod = $setupIntent->payment_method; $request->user() ->newSubscription('default', 'price_xxx') ->create($paymentMethod); return redirect('/dashboard'); })->name('subscription.complete');

새 구독을 생성하는 대신 고객의 기본 결제 수단을 변경하는 용도로 Payment Element를 사용할 경우, 결제 수단 식별자를 updateDefaultPaymentMethod 메서드에 전달하면 됩니다.

단건 결제용 Payment Element

단건 결제의 경우, Cashier의 pay 메서드로 Payment Intent를 생성합니다. 결제 완료 후 Stripe가 고객을 애플리케이션으로 다시 리다이렉트할 때 주문을 조회할 수 있도록, Payment Intent ID를 주문 레코드에 저장해 두는 것이 좋습니다. 아래 예시는 user_id, amount, status, stripe_payment_intent_id 컬럼을 가진 Order 모델을 사용합니다.

use App\Models\Order; use Illuminate\Http\Request; Route::post('/pay', function (Request $request) { $amount = 1000; $payment = $request->user()->pay($amount); $order = Order::create([ 'user_id' => $request->user()->id, 'amount' => $amount, 'status' => 'pending', 'stripe_payment_intent_id' => $payment->id, ]); return view('checkout', [ 'clientSecret' => $payment->client_secret, 'order' => $order, ]); });

이제 Payment Element를 마운트하고 결제를 확인합니다.

<div id="payment-element"></div> <button id="submit">결제하기</button> <script src="https://js.stripe.com/v3/"></script> <script> const stripe = Stripe('stripe-public-key'); const elements = stripe.elements({ clientSecret: '{{ $clientSecret }}' }); const paymentElement = elements.create('payment'); paymentElement.mount('#payment-element'); document.getElementById('submit').addEventListener('click', async () => { const { error } = await stripe.confirmPayment({ elements, confirmParams: { return_url: '{{ route("payment.complete") }}', }, }); if (error) { // error.message를 사용자에게 표시... } }); </script>

리다이렉트 후에는 쿼리 파라미터 payment_intent로 주문과 Payment Intent를 조회할 수 있습니다. 주문을 처리하기 전에 반드시 해당 주문이 현재 인증된 사용자의 것인지, Payment Intent가 동일 고객에게 속하며 결제가 성공했는지 검증해야 합니다.

use App\Models\Order; use Illuminate\Http\Request; Route::get('/payment/complete', function (Request $request) { $order = Order::where('user_id', $request->user()->id) ->where('stripe_payment_intent_id', $request->payment_intent) ->firstOrFail(); $paymentIntent = $request->user() ->stripe() ->paymentIntents ->retrieve($request->payment_intent); if ($paymentIntent->customer === $request->user()->stripe_id && $paymentIntent->status === 'succeeded') { $order->update(['status' => 'paid']); // 주문 처리 로직... } return redirect('/dashboard'); })->name('payment.complete');

결제 수단 조회

청구 가능 모델 인스턴스의 paymentMethods 메서드는 Laravel\Cashier\PaymentMethod 인스턴스의 컬렉션을 반환합니다.

$paymentMethods = $user->paymentMethods();

기본적으로 모든 유형의 결제 수단이 반환됩니다. 특정 유형만 조회하려면 type을 인자로 전달합니다.

$paymentMethods = $user->paymentMethods('sepa_debit');

기본 결제 수단을 조회하려면 defaultPaymentMethod 메서드를 사용합니다.

$paymentMethod = $user->defaultPaymentMethod();

청구 가능 모델에 연결된 특정 결제 수단을 조회하려면 findPaymentMethod 메서드를 사용합니다.

$paymentMethod = $user->findPaymentMethod($paymentMethodId);

결제 수단 존재 여부 확인

청구 가능 모델에 기본 결제 수단이 등록되어 있는지 확인하려면 hasDefaultPaymentMethod 메서드를 사용합니다.

if ($user->hasDefaultPaymentMethod()) { // ... }

최소 하나 이상의 결제 수단이 등록되어 있는지 확인하려면 hasPaymentMethod 메서드를 사용합니다.

if ($user->hasPaymentMethod()) { // ... }

특정 유형의 결제 수단이 존재하는지 확인하려면 type을 인자로 전달합니다.

if ($user->hasPaymentMethod('sepa_debit')) { // ... }

기본 결제 수단 변경

updateDefaultPaymentMethod 메서드에 Stripe 결제 수단 식별자를 전달하면 기본 결제 수단을 변경할 수 있습니다.

$user->updateDefaultPaymentMethod($paymentMethod);

Stripe에 저장된 기본 결제 수단 정보와 애플리케이션의 데이터를 동기화하려면 updateDefaultPaymentMethodFromStripe 메서드를 사용합니다.

$user->updateDefaultPaymentMethodFromStripe();

WARNING

고객의 기본 결제 수단은 청구서 발행과 새 구독 생성에만 사용할 수 있습니다. Stripe의 제약으로 인해 단건 결제에는 사용할 수 없습니다.

결제 수단 추가

청구 가능 모델에 결제 수단을 추가하려면 addPaymentMethod 메서드에 결제 수단 식별자를 전달합니다.

$user->addPaymentMethod($paymentMethod);

NOTE

결제 수단 식별자를 얻는 방법은 결제 수단 저장 문서를 참고하세요.

결제 수단 삭제

특정 결제 수단을 삭제하려면 Laravel\Cashier\PaymentMethod 인스턴스의 delete 메서드를 호출합니다.

$paymentMethod->delete();

deletePaymentMethod 메서드를 사용하면 특정 결제 수단을 청구 가능 모델에서 삭제할 수 있습니다.

$user->deletePaymentMethod('pm_visa');

deletePaymentMethods 메서드를 사용하면 청구 가능 모델의 모든 결제 수단을 삭제합니다.

$user->deletePaymentMethods();

기본적으로 모든 유형의 결제 수단이 삭제됩니다. 특정 유형만 삭제하려면 type을 인자로 전달합니다.

$user->deletePaymentMethods('sepa_debit');

WARNING

사용자에게 활성 구독이 있는 경우, 기본 결제 수단을 삭제하지 못하도록 애플리케이션에서 반드시 제한해야 합니다.

구독(Subscriptions)

구독은 고객에게 정기 결제를 설정하는 기능입니다. Cashier로 관리하는 Stripe 구독은 복수 구독 요금제, 구독 수량, 체험 기간(trial) 등 다양한 기능을 지원합니다.

구독 생성

구독을 생성하려면 먼저 청구 가능한 모델(보통 App\Models\User)의 인스턴스를 가져옵니다. 그런 다음 newSubscription 메서드를 사용해 구독을 생성할 수 있습니다.

use Illuminate\Http\Request; Route::post('/user/subscribe', function (Request $request) { $request->user()->newSubscription( 'default', 'price_monthly' )->create($request->paymentMethodId); // ... });

newSubscription 메서드의 첫 번째 인자는 구독의 내부 타입 이름입니다. 애플리케이션에 구독이 하나뿐이라면 default 또는 primary와 같은 이름을 사용할 수 있습니다. 이 타입 이름은 애플리케이션 내부에서만 사용하는 식별자로, 사용자에게 노출되지 않습니다. 공백을 포함하면 안 되며, 구독 생성 후에는 절대 변경하지 않아야 합니다. 두 번째 인자는 Stripe에서 설정한 가격 식별자(price ID)입니다.

create 메서드는 Stripe 결제 수단 식별자 또는 Stripe PaymentMethod 객체를 받아 구독을 시작하고, 청구 모델의 Stripe 고객 ID 등 관련 결제 정보를 데이터베이스에 저장합니다.

WARNING

create 메서드에 결제 수단 식별자를 직접 전달하면, 해당 결제 수단이 사용자의 저장된 결제 수단 목록에도 자동으로 추가됩니다.

인보이스 이메일을 통한 정기 결제 수집

정기 결제를 자동으로 청구하는 대신, 결제 기일이 됐을 때 Stripe가 고객에게 인보이스 이메일을 발송하도록 설정할 수 있습니다. 고객은 이메일을 받은 후 직접 인보이스를 결제합니다. 이 방식에서는 구독 생성 시 결제 수단을 미리 등록할 필요가 없습니다.

$user->newSubscription('default', 'price_monthly')->createAndSendInvoice();

인보이스 미결제 시 구독이 취소되기까지의 유예 기간은 days_until_due 옵션으로 설정합니다. 기본값은 30일이며, 필요하면 직접 지정할 수 있습니다.

$user->newSubscription('default', 'price_monthly')->createAndSendInvoice([], [ 'days_until_due' => 30 ]);

구독 수량

구독 생성 시 특정 수량을 지정하려면 create 호출 전에 quantity 메서드를 체이닝합니다.

$user->newSubscription('default', 'price_monthly') ->quantity(5) ->create($paymentMethod);

추가 옵션 전달

Stripe가 지원하는 추가 고객 또는 구독 옵션이 필요한 경우, create 메서드의 두 번째와 세 번째 인자로 전달할 수 있습니다.

$user->newSubscription('default', 'price_monthly')->create($paymentMethod, [ 'email' => $email, ], [ 'metadata' => ['note' => '추가 메모 정보'], ]);

쿠폰

구독 생성 시 쿠폰을 적용하려면 withCoupon 메서드를 사용합니다.

$user->newSubscription('default', 'price_monthly') ->withCoupon('code') ->create($paymentMethod);

Stripe 프로모션 코드를 적용하려면 withPromotionCode 메서드를 사용합니다.

$user->newSubscription('default', 'price_monthly') ->withPromotionCode('promo_code_id') ->create($paymentMethod);

여기서 전달하는 프로모션 코드 ID는 Stripe API에서 사용하는 내부 ID입니다. 고객에게 노출되는 프로모션 코드 문자열(예: SUMMERSALE)이 아닙니다. 고객용 코드 문자열로 API ID를 조회하려면 findPromotionCode 메서드를 사용합니다.

// 고객용 코드로 프로모션 코드 ID 조회... $promotionCode = $user->findPromotionCode('SUMMERSALE'); // 활성화된 프로모션 코드만 조회... $promotionCode = $user->findActivePromotionCode('SUMMERSALE');

반환된 $promotionCode 객체는 Laravel\Cashier\PromotionCode 인스턴스로, 내부적으로 Stripe\PromotionCode 객체를 래핑합니다. 연관된 쿠폰 정보는 coupon 메서드로 가져올 수 있습니다.

$coupon = $user->findPromotionCode('SUMMERSALE')->coupon();

쿠폰 인스턴스를 통해 할인 금액이나 비율 등의 정보를 확인할 수 있습니다.

if ($coupon->isPercentage()) { return $coupon->percentOff().'%'; // 21.5% } else { return $coupon->amountOff(); // $5.99 }

현재 고객 또는 구독에 적용된 할인 정보는 다음과 같이 조회합니다.

$discount = $billable->discount(); $discount = $subscription->discount();

반환된 Laravel\Cashier\Discount 인스턴스는 Stripe\Discount 객체를 래핑합니다. 연관 쿠폰은 coupon 메서드로 가져옵니다.

$coupon = $subscription->discount()->coupon();

새 쿠폰이나 프로모션 코드를 고객 또는 구독에 적용하려면 applyCoupon 또는 applyPromotionCode 메서드를 사용합니다.

$billable->applyCoupon('coupon_id'); $billable->applyPromotionCode('promotion_code_id'); $subscription->applyCoupon('coupon_id'); $subscription->applyPromotionCode('promotion_code_id');

고객용 코드 문자열이 아닌 Stripe API ID를 사용해야 한다는 점에 주의하세요. 고객 또는 구독에는 한 번에 하나의 쿠폰 또는 프로모션 코드만 적용할 수 있습니다.

자세한 내용은 Stripe의 쿠폰프로모션 코드 문서를 참고하세요.

기본 결제 수단이 있는 고객에게 구독 추가

이미 기본 결제 수단이 등록된 고객에게 구독을 추가하려면 구독 빌더의 add 메서드를 사용합니다.

use App\Models\User; $user = User::find(1); $user->newSubscription('default', 'price_monthly')->add();

Stripe 대시보드에서 구독 생성

Stripe 대시보드에서도 직접 구독을 생성할 수 있습니다. 이 경우 Cashier는 새로 추가된 구독을 동기화하고 default 타입을 자동으로 부여합니다. 타입을 커스터마이즈하려면 웹훅 이벤트 핸들러를 정의해야 합니다.

Stripe 대시보드를 통해서는 한 가지 타입의 구독만 생성할 수 있습니다. 여러 타입의 구독을 제공하는 애플리케이션이라면 대시보드를 통해 그 중 하나의 타입만 추가할 수 있습니다.

또한 동일한 타입의 활성 구독은 하나만 유지해야 합니다. 같은 default 타입의 구독이 두 개 존재하면, Cashier는 두 구독 모두 데이터베이스에 동기화하지만 가장 최근 구독만 사용합니다.

구독 상태 확인

고객이 구독을 시작하면 다양한 편의 메서드로 구독 상태를 확인할 수 있습니다. 먼저 subscribed 메서드는 체험 기간 중인 경우를 포함하여 활성 구독이 있으면 true를 반환합니다. 구독 타입을 인자로 전달합니다.

if ($user->subscribed('default')) { // ... }

subscribed 메서드는 라우트 미들웨어에서 구독 상태에 따라 라우트 접근을 제어하는 용도로도 활용할 수 있습니다.

<?php namespace App\Http\Middleware; use Closure; use Illuminate\Http\Request; use Symfony\Component\HttpFoundation\Response; class EnsureUserIsSubscribed { /** * 요청을 처리합니다. * * @param \Closure(\Illuminate\Http\Request): (\Symfony\Component\HttpFoundation\Response) $next */ public function handle(Request $request, Closure $next): Response { if ($request->user() && ! $request->user()->subscribed('default')) { // 유료 구독자가 아닙니다... return redirect('/billing'); } return $next($request); } }

사용자가 체험 기간 중인지 확인하려면 onTrial 메서드를 사용합니다. 체험 기간 중 사용자에게 안내 메시지를 표시할 때 유용합니다.

if ($user->subscription('default')->onTrial()) { // ... }

subscribedToProduct 메서드는 사용자가 특정 Stripe 상품을 구독 중인지 확인합니다. Stripe에서 상품은 여러 가격(price)의 묶음입니다. 아래 예시에서는 default 구독이 "premium" 상품을 활성 구독 중인지 확인합니다. 상품 식별자는 Stripe 대시보드의 상품 ID여야 합니다.

if ($user->subscribedToProduct('prod_premium', 'default')) { // ... }

배열을 전달하면 여러 상품 중 하나라도 구독 중인지 확인할 수 있습니다.

if ($user->subscribedToProduct(['prod_basic', 'prod_premium'], 'default')) { // ... }

subscribedToPrice 메서드는 특정 가격 ID에 해당하는 구독이 있는지 확인합니다.

if ($user->subscribedToPrice('price_basic_monthly', 'default')) { // ... }

recurring 메서드는 사용자가 현재 구독 중이며 체험 기간이 종료된 상태인지 확인합니다.

if ($user->subscription('default')->recurring()) { // ... }

WARNING

동일한 타입의 구독이 두 개 이상 있는 경우, subscription 메서드는 항상 가장 최근 구독을 반환합니다. 예를 들어 default 타입의 구독 레코드가 두 개 있을 때, 하나는 만료된 구독이고 다른 하나가 현재 활성 구독이라면 가장 최근 것을 반환합니다. 오래된 구독은 이력 조회를 위해 데이터베이스에 유지됩니다.

구독 취소 상태 확인

과거에 구독했다가 취소한 사용자인지 확인하려면 canceled 메서드를 사용합니다.

if ($user->subscription('default')->canceled()) { // ... }

구독을 취소했지만 아직 유예 기간(grace period) 중인지도 확인할 수 있습니다. 예를 들어 3월 5일에 만료 예정인 구독을 3월 1일에 취소했다면, 3월 5일까지는 유예 기간입니다. 유예 기간 동안에는 subscribed 메서드가 여전히 true를 반환합니다.

if ($user->subscription('default')->onGracePeriod()) { // ... }

구독이 취소됐고 유예 기간도 완전히 끝났는지 확인하려면 ended 메서드를 사용합니다.

if ($user->subscription('default')->ended()) { // ... }

미완료(Incomplete) 및 연체(Past Due) 상태

구독 생성 후 추가 결제 인증(예: 3D Secure)이 필요한 경우 구독 상태가 incomplete로 표시됩니다. 가격 변경(swap) 시 추가 인증이 필요하면 past_due 상태가 됩니다. 두 상태 모두 고객이 결제를 확인할 때까지 구독이 활성화되지 않습니다. 미완료 결제가 있는지는 hasIncompletePayment 메서드로 확인합니다.

if ($user->hasIncompletePayment('default')) { // ... } if ($user->subscription('default')->hasIncompletePayment()) { // ... }

미완료 결제가 있는 경우 Cashier의 결제 확인 페이지로 사용자를 안내해야 합니다. latestPayment 메서드로 최신 결제 ID를 가져올 수 있습니다.

<a href="{{ route('cashier.payment', $subscription->latestPayment()->id) }}"> 결제를 확인해 주세요. </a>

past_due 또는 incomplete 상태에서도 구독을 활성으로 간주하고 싶다면, AppServiceProviderregister 메서드에서 Cashier가 제공하는 메서드를 호출합니다.

use Laravel\Cashier\Cashier; /** * 애플리케이션 서비스를 등록합니다. */ public function register(): void { Cashier::keepPastDueSubscriptionsActive(); Cashier::keepIncompleteSubscriptionsActive(); }

WARNING

incomplete 상태의 구독은 결제가 확인될 때까지 변경할 수 없습니다. swap이나 updateQuantity 메서드를 호출하면 예외가 발생합니다.

구독 쿼리 스코프

대부분의 구독 상태는 Eloquent 쿼리 스코프로도 제공됩니다. 특정 상태의 구독을 데이터베이스에서 간편하게 조회할 수 있습니다.

// 모든 활성 구독 조회... $subscriptions = Subscription::query()->active()->get(); // 특정 사용자의 취소된 구독 조회... $subscriptions = $user->subscriptions()->canceled()->get();

사용 가능한 전체 스코프 목록은 다음과 같습니다.

Subscription::query()->active(); Subscription::query()->canceled(); Subscription::query()->ended(); Subscription::query()->incomplete(); Subscription::query()->notCanceled(); Subscription::query()->notOnGracePeriod(); Subscription::query()->notOnTrial(); Subscription::query()->onGracePeriod(); Subscription::query()->onTrial(); Subscription::query()->pastDue(); Subscription::query()->recurring();

가격 변경

고객이 구독 중인 가격을 변경하려면 swap 메서드에 Stripe 가격 식별자를 전달합니다. 이전에 취소된 구독이라면 재활성화하는 것으로 간주합니다.

use App\Models\User; $user = App\Models\User::find(1); $user->subscription('default')->swap('price_yearly');

체험 기간 중인 고객은 체험 기간이 유지됩니다. 또한 구독 수량이 설정되어 있다면 수량도 유지됩니다.

체험 기간을 종료하고 가격을 변경하려면 skipTrial 메서드를 함께 사용합니다.

$user->subscription('default') ->skipTrial() ->swap('price_yearly');

다음 청구 주기를 기다리지 않고 가격 변경 즉시 인보이스를 발행하려면 swapAndInvoice 메서드를 사용합니다.

$user = User::find(1); $user->subscription('default')->swapAndInvoice('price_yearly');

일할 계산(Proration)

기본적으로 Stripe는 가격 변경 시 일할 계산하여 요금을 부과합니다. 일할 계산 없이 가격만 변경하려면 noProrate 메서드를 사용합니다.

$user->subscription('default')->noProrate()->swap('price_yearly');

구독 일할 계산에 대한 자세한 내용은 Stripe 문서를 참고하세요.

WARNING

swapAndInvoice 전에 noProrate를 호출해도 일할 계산에는 영향을 주지 않습니다. 인보이스는 항상 발행됩니다.

구독 수량

구독 수량은 좌석 수나 프로젝트 수에 따라 요금을 부과하는 방식에 활용됩니다. 예를 들어 프로젝트 관리 앱에서 프로젝트당 월 1만 원을 청구하는 경우입니다. incrementQuantitydecrementQuantity 메서드로 수량을 조정할 수 있습니다.

use App\Models\User; $user = User::find(1); $user->subscription('default')->incrementQuantity(); // 현재 수량에서 5 증가... $user->subscription('default')->incrementQuantity(5); $user->subscription('default')->decrementQuantity(); // 현재 수량에서 5 감소... $user->subscription('default')->decrementQuantity(5);

특정 수량으로 직접 설정하려면 updateQuantity 메서드를 사용합니다.

$user->subscription('default')->updateQuantity(10);

일할 계산 없이 수량만 변경하려면 noProrate 메서드를 함께 사용합니다.

$user->subscription('default')->noProrate()->updateQuantity(10);

구독 수량에 대한 자세한 내용은 Stripe 문서를 참고하세요.

복수 상품 구독에서의 수량 관리

복수 상품 구독에서 특정 가격의 수량을 변경하려면, 증감 메서드의 두 번째 인자로 가격 ID를 전달합니다.

$user->subscription('default')->incrementQuantity(1, 'price_chat');

복수 상품 구독

복수 상품 구독을 이용하면 하나의 구독에 여러 결제 상품을 연결할 수 있습니다. 예를 들어 월 1만 원의 기본 구독에 월 1만 5천 원짜리 라이브 채팅 애드온을 추가하는 식입니다. 복수 상품 구독 정보는 Cashier의 subscription_items 테이블에 저장됩니다.

newSubscription 메서드의 두 번째 인자로 가격 배열을 전달하면 복수 상품 구독을 생성할 수 있습니다.

use Illuminate\Http\Request; Route::post('/user/subscribe', function (Request $request) { $request->user()->newSubscription('default', [ 'price_monthly', 'price_chat', ])->create($request->paymentMethodId); // ... });

위 예시에서 고객의 default 구독에는 두 가격이 각자의 청구 주기에 따라 청구됩니다. 필요하다면 quantity 메서드로 각 가격별 수량을 지정할 수도 있습니다.

$user = User::find(1); $user->newSubscription('default', ['price_monthly', 'price_chat']) ->quantity(5, 'price_chat') ->create($paymentMethod);

기존 구독에 새 가격을 추가하려면 addPrice 메서드를 사용합니다.

$user = User::find(1); $user->subscription('default')->addPrice('price_chat');

위 코드는 다음 청구 주기에 새 가격이 청구됩니다. 즉시 청구하려면 addPriceAndInvoice 메서드를 사용합니다.

$user->subscription('default')->addPriceAndInvoice('price_chat');

특정 수량으로 가격을 추가하려면 addPrice 또는 addPriceAndInvoice의 두 번째 인자로 수량을 전달합니다.

$user = User::find(1); $user->subscription('default')->addPrice('price_chat', 5);

구독에서 가격을 제거하려면 removePrice 메서드를 사용합니다.

$user->subscription('default')->removePrice('price_chat');

WARNING

구독의 마지막 가격은 제거할 수 없습니다. 마지막 가격을 제거해야 한다면 구독 자체를 취소해야 합니다.

가격 교체

복수 상품 구독에서 특정 가격을 다른 가격으로 교체할 수 있습니다. 예를 들어 price_basic 구독에 price_chat 애드온이 있는 고객을 price_pro로 업그레이드하는 경우입니다.

use App\Models\User; $user = User::find(1); $user->subscription('default')->swap(['price_pro', 'price_chat']);

위 코드를 실행하면 price_basic 구독 항목은 삭제되고, price_chat은 유지되며, price_pro 구독 항목이 새로 추가됩니다.

swap 메서드에 키-값 배열을 전달하면 가격별 수량 등 옵션도 함께 지정할 수 있습니다.

$user = User::find(1); $user->subscription('default')->swap([ 'price_pro' => ['quantity' => 5], 'price_chat' ]);

구독 항목 자체에서 swap을 호출하면 다른 가격의 메타데이터를 그대로 보존하면서 해당 가격만 교체할 수 있습니다.

$user = User::find(1); $user->subscription('default') ->findItemOrFail('price_basic') ->swap('price_pro');

일할 계산(Proration)

복수 상품 구독에서 가격을 추가하거나 제거할 때도 기본적으로 일할 계산이 적용됩니다. 일할 계산 없이 처리하려면 noProrate를 체이닝합니다.

$user->subscription('default')->noProrate()->removePrice('price_chat');

개별 가격 수량 변경

복수 상품 구독에서 특정 가격의 수량만 변경하려면 기존 수량 메서드에 가격 ID를 추가 인자로 전달합니다.

$user = User::find(1); $user->subscription('default')->incrementQuantity(5, 'price_chat'); $user->subscription('default')->decrementQuantity(3, 'price_chat'); $user->subscription('default')->updateQuantity(10, 'price_chat');

WARNING

복수 상품 구독의 경우 Subscription 모델의 stripe_pricequantity 속성은 null이 됩니다. 개별 가격 정보는 Subscription 모델의 items 관계를 통해 접근해야 합니다.

구독 항목(Subscription Items)

복수 상품 구독은 데이터베이스의 subscription_items 테이블에 여러 구독 항목으로 저장됩니다. items 관계를 통해 접근할 수 있습니다.

use App\Models\User; $user = User::find(1); $subscriptionItem = $user->subscription('default')->items->first(); // 특정 항목의 Stripe 가격 및 수량 조회... $stripePrice = $subscriptionItem->stripe_price; $quantity = $subscriptionItem->quantity;

findItemOrFail 메서드로 특정 가격의 구독 항목을 직접 조회할 수도 있습니다.

$user = User::find(1); $subscriptionItem = $user->subscription('default')->findItemOrFail('price_chat');

복수 구독

Stripe는 고객이 여러 구독을 동시에 보유할 수 있도록 지원합니다. 예를 들어 헬스장 앱에서 수영 구독과 헬스 구독을 각각 다른 요금으로 제공하고, 고객은 둘 중 하나 또는 둘 모두를 구독할 수 있습니다.

구독 생성 시 newSubscription 메서드에 타입을 지정하면 됩니다. 타입은 구독의 종류를 나타내는 임의의 문자열입니다.

use Illuminate\Http\Request; Route::post('/swimming/subscribe', function (Request $request) { $request->user()->newSubscription('swimming') ->price('price_swimming_monthly') ->create($request->paymentMethodId); // ... });

이 고객이 나중에 연간 구독으로 변경하고 싶다면, swimming 구독의 가격을 교체합니다.

$user->subscription('swimming')->swap('price_swimming_yearly');

구독을 완전히 취소할 수도 있습니다.

$user->subscription('swimming')->cancel();

사용량 기반 청구

사용량 기반 청구(Usage Based Billing)를 이용하면 청구 주기 동안 고객이 사용한 양에 따라 요금을 부과할 수 있습니다. 예를 들어 월간 발송 문자 수나 이메일 수에 따라 청구하는 방식입니다.

사용량 기반 청구를 시작하려면 먼저 Stripe 대시보드에서 사용량 기반 청구 모델미터(meter)를 생성해야 합니다. 미터를 생성한 후 관련 이벤트 이름과 미터 ID를 저장해두세요. 이후 meteredPrice 메서드로 고객 구독에 미터링 가격을 추가합니다.

use Illuminate\Http\Request; Route::post('/user/subscribe', function (Request $request) { $request->user()->newSubscription('default') ->meteredPrice('price_metered') ->create($request->paymentMethodId); // ... });

Stripe Checkout을 통해 미터링 구독을 시작할 수도 있습니다.

$checkout = Auth::user() ->newSubscription('default', []) ->meteredPrice('price_metered') ->checkout(); return view('your-checkout-view', [ 'checkout' => $checkout, ]);

사용량 보고

고객이 애플리케이션을 사용할 때마다 Stripe에 사용량을 보고해야 정확한 청구가 이루어집니다. Billable 모델의 reportMeterEvent 메서드를 사용합니다.

$user = User::find(1); $user->reportMeterEvent('emails-sent');

기본적으로 사용량 1이 청구 주기에 추가됩니다. 특정 수량을 지정하려면 quantity를 전달합니다.

$user = User::find(1); $user->reportMeterEvent('emails-sent', quantity: 15);

특정 미터에 대한 고객의 이벤트 요약을 조회하려면 meterEventSummaries 메서드를 사용합니다.

$user = User::find(1); $meterUsage = $user->meterEventSummaries($meterId); $meterUsage->first()->aggregated_value // 10

미터 이벤트 요약 객체에 대한 자세한 내용은 Stripe의 Meter Event Summary 객체 문서를 참고하세요.

모든 미터 목록을 조회하려면 meters 메서드를 사용합니다.

$user = User::find(1); $user->meters();

구독 세금

WARNING

세율을 직접 계산하는 대신 Stripe Tax를 이용한 자동 세금 계산을 사용하는 것을 권장합니다.

청구 모델에 taxRates 메서드를 구현하고 Stripe 세율 ID 배열을 반환하면 구독에 세율을 적용할 수 있습니다. 세율은 Stripe 대시보드에서 정의합니다.

/** * 고객 구독에 적용할 세율. * * @return array<int, string> */ public function taxRates(): array { return ['txr_id']; }

taxRates 메서드를 사용하면 고객별로 다른 세율을 적용할 수 있어, 다양한 국가의 사용자를 지원하는 서비스에 유용합니다.

복수 상품 구독에서 가격별로 다른 세율을 적용하려면 priceTaxRates 메서드를 구현합니다.

/** * 고객 구독에 적용할 세율. * * @return array<string, array<int, string>> */ public function priceTaxRates(): array { return [ 'price_monthly' => ['txr_id'], ]; }

WARNING

taxRates 메서드는 구독 청구에만 적용됩니다. Cashier로 단건 청구를 할 때는 세율을 직접 지정해야 합니다.

세율 동기화

taxRates 메서드가 반환하는 세율 ID를 변경해도 기존 구독의 세율 설정은 자동으로 변경되지 않습니다. 기존 구독에도 새 세율을 적용하려면 구독 인스턴스에서 syncTaxRates 메서드를 호출합니다.

$user->subscription('default')->syncTaxRates();

복수 상품 구독의 항목별 세율도 함께 동기화됩니다. 복수 상품 구독을 제공하는 경우 청구 모델에 priceTaxRates 메서드가 구현되어 있는지 확인하세요.

세금 면제

Cashier는 고객의 세금 면제 상태를 확인하는 isNotTaxExempt, isTaxExempt, reverseChargeApplies 메서드도 제공합니다. 이 메서드들은 Stripe API를 호출하여 상태를 확인합니다.

use App\Models\User; $user = User::find(1); $user->isTaxExempt(); $user->isNotTaxExempt(); $user->reverseChargeApplies();

WARNING

이 메서드들은 Laravel\Cashier\Invoice 객체에서도 사용할 수 있습니다. 단, Invoice 객체에서 호출하면 인보이스 생성 시점의 면제 상태를 기준으로 판단합니다.

구독 청구 기준일(Anchor Date)

기본적으로 청구 주기의 기준일은 구독 생성일이며, 체험 기간이 있다면 체험 종료일이 기준이 됩니다. anchorBillingCycleOn 메서드로 청구 기준일을 변경할 수 있습니다.

use Illuminate\Http\Request; Route::post('/user/subscribe', function (Request $request) { $anchor = Carbon::parse('first day of next month'); $request->user()->newSubscription('default', 'price_monthly') ->anchorBillingCycleOn($anchor->startOfDay()) ->create($request->paymentMethodId); // ... });

구독 청구 주기 관리에 대한 자세한 내용은 Stripe 청구 주기 문서를 참고하세요.

구독 취소

구독을 취소하려면 구독 인스턴스의 cancel 메서드를 호출합니다.

$user->subscription('default')->cancel();

구독이 취소되면 Cashier는 subscriptions 테이블의 ends_at 컬럼을 자동으로 설정합니다. 이 값을 기준으로 subscribed 메서드의 반환값이 결정됩니다.

예를 들어 3월 1일에 취소했지만 구독이 3월 5일에 만료 예정이라면, subscribed 메서드는 3월 5일까지 true를 반환합니다. 이는 일반적으로 청구 주기가 끝날 때까지 서비스 이용을 허용하기 위함입니다.

유예 기간 중인지 확인하려면 onGracePeriod 메서드를 사용합니다.

if ($user->subscription('default')->onGracePeriod()) { // ... }

즉시 구독을 취소하려면 cancelNow 메서드를 사용합니다.

$user->subscription('default')->cancelNow();

즉시 취소하면서 미청구된 사용량이나 보류 중인 일할 계산 항목에 대한 인보이스도 함께 발행하려면 cancelNowAndInvoice 메서드를 사용합니다.

$user->subscription('default')->cancelNowAndInvoice();

특정 시점에 구독을 취소하도록 예약할 수도 있습니다.

$user->subscription('default')->cancelAt( now()->plus(days: 10) );

마지막으로, 사용자 모델을 삭제하기 전에 반드시 구독을 먼저 취소해야 합니다.

$user->subscription('default')->cancelNow(); $user->delete();

구독 재개

취소된 구독을 재개하려면 구독 인스턴스의 resume 메서드를 호출합니다. 구독을 재개하려면 반드시 유예 기간 내에 있어야 합니다.

$user->subscription('default')->resume();

구독을 취소했다가 만료 전에 재개하면 즉시 청구되지 않으며, 원래의 청구 주기에 따라 재활성화됩니다.

결제(Cashier)

구독 체험판(Trial)

결제 수단을 먼저 등록하는 체험판

결제 수단 정보를 미리 수집하면서 체험 기간을 제공하려면, 구독 생성 시 trialDays 메서드를 사용하세요:

use Illuminate\Http\Request; Route::post('/user/subscribe', function (Request $request) { $request->user()->newSubscription('default', 'price_monthly') ->trialDays(10) ->create($request->paymentMethodId); // ... });

이 메서드는 데이터베이스의 구독 레코드에 체험 종료일을 기록하고, Stripe에 해당 날짜 이후부터 청구를 시작하도록 지시합니다. trialDays를 사용하면 Stripe 대시보드에서 해당 가격(Price)에 설정된 기본 체험 기간은 Cashier가 덮어씁니다.

WARNING

체험 기간이 끝나기 전에 구독을 취소하지 않으면 체험이 만료되는 즉시 결제가 청구됩니다. 사용자에게 체험 종료일을 반드시 안내하세요.

특정 날짜를 직접 지정하려면 trialUntil 메서드에 DateTime 인스턴스를 전달하면 됩니다:

use Illuminate\Support\Carbon; $user->newSubscription('default', 'price_monthly') ->trialUntil(Carbon::now()->plus(days: 10)) ->create($paymentMethod);

사용자가 현재 체험 기간 중인지 확인할 때는 유저 인스턴스의 onTrial 메서드나 구독 인스턴스의 onTrial 메서드를 사용할 수 있습니다. 아래 두 예시는 동일하게 동작합니다:

if ($user->onTrial('default')) { // ... } if ($user->subscription('default')->onTrial()) { // ... }

체험 기간을 즉시 종료하려면 endTrial 메서드를 사용하세요:

$user->subscription('default')->endTrial();

체험 기간이 이미 만료되었는지 확인하려면 hasExpiredTrial 메서드를 사용하세요:

if ($user->hasExpiredTrial('default')) { // ... } if ($user->subscription('default')->hasExpiredTrial()) { // ... }

Stripe 대시보드 vs Cashier에서 체험 기간 설정

체험 일수는 Stripe 대시보드의 가격(Price) 설정에서 정의하거나, Cashier 코드에서 명시적으로 전달하는 두 가지 방법 중 하나를 선택할 수 있습니다.

Stripe 대시보드에서 체험 기간을 설정하는 경우 주의할 점이 있습니다. 이전에 구독 이력이 있는 고객을 포함해 모든 신규 구독에 체험 기간이 자동으로 적용됩니다. 체험을 건너뛰려면 구독 생성 시 반드시 skipTrial() 메서드를 명시적으로 호출해야 합니다.

결제 수단 없이 시작하는 체험판

결제 수단 정보를 수집하지 않고 체험 기간을 제공하려면, 유저 레코드의 trial_ends_at 컬럼에 원하는 체험 종료일을 직접 설정하면 됩니다. 보통 회원 가입 시점에 함께 처리합니다:

use App\Models\User; $user = User::create([ // ... 'trial_ends_at' => now()->plus(days: 10), ]);

WARNING

Billable 모델 클래스에 trial_ends_at 속성에 대한 날짜 캐스트를 반드시 추가하세요.

Cashier는 이 방식의 체험을 "일반 체험(Generic Trial)" 이라고 부릅니다. 실제 구독과 연결되지 않은 독립적인 체험 기간이기 때문입니다. 현재 날짜가 trial_ends_at을 지나지 않았다면 onTrial 메서드는 true를 반환합니다:

if ($user->onTrial()) { // 사용자가 체험 기간 중입니다... }

실제 구독을 생성할 준비가 되면 평소처럼 newSubscription 메서드를 사용하면 됩니다:

$user = User::find(1); $user->newSubscription('default', 'price_monthly')->create($paymentMethod);

사용자의 체험 종료일을 가져오려면 trialEndsAt 메서드를 사용하세요. 체험 중이면 Carbon 인스턴스를, 체험 중이 아니면 null을 반환합니다. 특정 구독 타입의 종료일을 조회하려면 선택적 파라미터로 구독 타입을 전달할 수도 있습니다:

if ($user->onTrial()) { $trialEndsAt = $user->trialEndsAt('main'); }

아직 실제 구독을 생성하지 않고 "일반 체험" 기간에 있는 상태인지 구체적으로 확인하려면 onGenericTrial 메서드를 사용하세요:

if ($user->onGenericTrial()) { // 사용자가 "일반 체험" 기간 중입니다 (구독 미생성 상태)... }

체험 기간 연장

extendTrial 메서드를 사용하면 구독이 생성된 이후에도 체험 기간을 연장할 수 있습니다. 체험이 이미 만료되어 고객에게 청구가 시작된 상태라도 연장 체험을 제공할 수 있으며, 연장된 체험 기간 동안의 금액은 다음 청구서에서 차감됩니다:

use App\Models\User; $subscription = User::find(1)->subscription('default'); // 지금으로부터 7일 후에 체험을 종료... $subscription->extendTrial( now()->plus(days: 7) ); // 기존 체험 종료일에서 5일 추가 연장... $subscription->extendTrial( $subscription->trial_ends_at->plus(days: 5) );

Stripe 웹훅(Webhook) 처리

NOTE

로컬 개발 환경에서 웹훅을 테스트할 때는 Stripe CLI를 활용하면 편리합니다.

Stripe는 다양한 이벤트가 발생할 때 웹훅을 통해 여러분의 애플리케이션에 알림을 전송할 수 있습니다. Cashier 서비스 프로바이더는 웹훅 컨트롤러로 연결되는 라우트를 자동으로 등록합니다. 이 컨트롤러가 수신되는 모든 웹훅 요청을 처리합니다.

기본적으로 Cashier 웹훅 컨트롤러는 다음 상황을 자동으로 처리합니다.

  • 결제 실패가 너무 많을 때 구독 취소 (Stripe 설정에 따라 다름)
  • 고객 정보 업데이트 및 삭제
  • 구독 업데이트
  • 결제 수단 변경

이 외에도 원하는 Stripe 웹훅 이벤트를 추가로 처리할 수 있으며, 방법은 뒤에서 설명합니다.

애플리케이션이 Stripe 웹훅을 정상적으로 수신하려면, Stripe 대시보드에서 웹훅 URL을 설정해야 합니다. Cashier 웹훅 컨트롤러는 기본적으로 /stripe/webhook 경로로 요청을 수신합니다. Stripe 대시보드에서 활성화해야 할 웹훅 이벤트 목록은 다음과 같습니다.

  • customer.subscription.created
  • customer.subscription.updated
  • customer.subscription.deleted
  • customer.updated
  • customer.deleted
  • payment_method.automatically_updated
  • invoice.payment_action_required
  • invoice.payment_succeeded

Cashier는 이 설정을 편리하게 처리할 수 있도록 cashier:webhook Artisan 명령어를 제공합니다. 이 명령어를 실행하면 Cashier가 필요로 하는 모든 이벤트를 수신하는 웹훅이 Stripe에 자동으로 생성됩니다.

php artisan cashier:webhook

생성되는 웹훅은 기본적으로 APP_URL 환경 변수와 Cashier의 cashier.webhook 라우트를 조합한 URL을 사용합니다. 다른 URL을 사용하고 싶다면 --url 옵션을 지정하세요.

php artisan cashier:webhook --url "https://example.com/stripe/webhook"

생성되는 웹훅은 현재 Cashier 버전과 호환되는 Stripe API 버전을 사용합니다. 다른 Stripe API 버전을 사용하려면 --api-version 옵션을 지정하세요.

php artisan cashier:webhook --api-version="2019-12-03"

웹훅은 생성 즉시 활성화됩니다. 준비가 완료될 때까지 비활성 상태로 생성해 두려면 --disabled 옵션을 사용하세요.

php artisan cashier:webhook --disabled

WARNING

Stripe에서 수신되는 웹훅 요청은 반드시 Cashier의 웹훅 서명 검증 미들웨어로 보호해야 합니다.

웹훅과 CSRF 보호

Stripe 웹훅은 Laravel의 CSRF 보호를 우회해야 합니다. Stripe에서 전송되는 웹훅 요청에 대해 Laravel이 CSRF 토큰 검증을 시도하지 않도록, bootstrap/app.php 파일에서 stripe/* 경로를 CSRF 보호 대상에서 제외해야 합니다.

->withMiddleware(function (Middleware $middleware): void { $middleware->preventRequestForgery(except: [ 'stripe/*', ]); })

웹훅 이벤트 핸들러 정의

Cashier는 결제 실패로 인한 구독 취소 등 주요 Stripe 웹훅 이벤트를 자동으로 처리합니다. 그러나 추가로 처리하고 싶은 웹훅 이벤트가 있다면, Cashier가 디스패치하는 다음 이벤트에 리스너를 등록하면 됩니다.

  • Laravel\Cashier\Events\WebhookReceived — 웹훅 수신 시 발생
  • Laravel\Cashier\Events\WebhookHandled — 웹훅 처리 완료 시 발생

두 이벤트 모두 Stripe 웹훅의 전체 페이로드를 포함합니다. 예를 들어 invoice.payment_succeeded 웹훅을 처리하려면 다음과 같이 리스너를 등록하세요.

<?php namespace App\Listeners; use Laravel\Cashier\Events\WebhookReceived; class StripeEventListener { /** * 수신된 Stripe 웹훅을 처리합니다. */ public function handle(WebhookReceived $event): void { if ($event->payload['type'] === 'invoice.payment_succeeded') { // 이벤트 처리 로직... } } }

웹훅 서명 검증

웹훅 보안을 강화하려면 Stripe의 웹훅 서명을 활용하세요. Cashier는 수신된 Stripe 웹훅 요청의 유효성을 검증하는 미들웨어를 기본으로 포함하고 있습니다.

웹훅 서명 검증을 활성화하려면 애플리케이션의 .env 파일에 STRIPE_WEBHOOK_SECRET 환경 변수를 설정하세요. 웹훅 시크릿(secret) 값은 Stripe 계정 대시보드에서 확인할 수 있습니다.

결제(Cashier)

단건 결제

단순 결제

결제 수단 식별자를 사용해 고객에게 일회성 결제를 청구하려면 Billable 모델 인스턴스의 charge 메서드를 사용하세요. 단건 결제 처리 전에 고객으로부터 결제 정보를 직접 수집해야 한다면 단건 결제용 Payment Element 문서를 참고하세요.

use Illuminate\Http\Request; Route::post('/purchase', function (Request $request) { $payment = $request->user()->charge( 100, $request->paymentMethodId ); // ... });

charge 메서드는 세 번째 인수로 배열을 받으며, 이를 통해 Stripe Payment Intent 생성 시 원하는 옵션을 자유롭게 전달할 수 있습니다. Payment Intent 생성 시 사용 가능한 옵션에 대한 자세한 내용은 Stripe 공식 문서를 참고하세요.

$user->charge(100, $paymentMethod, [ 'custom_option' => $value, ]);

기존 고객이나 사용자 없이도 charge 메서드를 사용할 수 있습니다. 이 경우 애플리케이션의 Billable 모델 신규 인스턴스에 charge 메서드를 호출하면 됩니다.

use App\Models\User; $payment = (new User)->charge(100, $paymentMethod);

결제에 실패하면 charge 메서드는 예외를 던집니다. 결제가 성공하면 Laravel\Cashier\Payment 인스턴스를 반환합니다.

try { $payment = $user->charge(100, $paymentMethod); } catch (Exception $e) { // ... }

WARNING

charge 메서드는 애플리케이션에서 사용하는 통화의 최소 단위로 금액을 받습니다. 예를 들어 원화(KRW)를 사용하는 경우 금액을 원 단위 정수로 지정하고, 달러(USD)를 사용하는 경우 센트 단위로 지정해야 합니다.

인보이스와 함께 결제

일회성 결제를 처리하면서 고객에게 PDF 인보이스도 함께 제공해야 하는 경우 invoicePrice 메서드를 사용하세요. 예를 들어 티셔츠 5벌에 대한 인보이스를 발행하는 예시는 다음과 같습니다.

$user->invoicePrice('price_tshirt', 5);

인보이스 금액은 즉시 사용자의 기본 결제 수단으로 청구됩니다. invoicePrice 메서드는 세 번째 인수로 인보이스 항목의 청구 옵션 배열을, 네 번째 인수로 인보이스 자체의 청구 옵션 배열을 받습니다.

$user->invoicePrice('price_tshirt', 5, [ 'discounts' => [ ['coupon' => 'SUMMER21SALE'] ], ], [ 'default_tax_rates' => ['txr_id'], ]);

invoicePrice와 유사하게, tabPrice 메서드를 사용하면 여러 항목(인보이스당 최대 250개)을 고객의 "탭(tab)"에 추가한 뒤 한꺼번에 인보이스를 발행할 수 있습니다. 예를 들어 티셔츠 5벌과 머그컵 2개를 한 장의 인보이스로 청구하는 방법은 다음과 같습니다.

$user->tabPrice('price_tshirt', 5); $user->tabPrice('price_mug', 2); $user->invoice();

또는 invoiceFor 메서드를 사용해 고객의 기본 결제 수단으로 단건 결제를 처리할 수도 있습니다.

$user->invoiceFor('One Time Fee', 500);

invoiceFor 메서드도 사용 가능하지만, 가능하면 Stripe 대시보드에서 미리 정의한 가격을 활용하는 invoicePricetabPrice 메서드를 사용하는 것을 권장합니다. 이렇게 하면 Stripe 대시보드에서 상품별 매출 분석 데이터를 더 체계적으로 확인할 수 있습니다.

WARNING

invoice, invoicePrice, invoiceFor 메서드는 Stripe 인보이스를 생성하며, 결제 실패 시 자동으로 재시도합니다. 실패한 결제를 재시도하지 않으려면 첫 번째 결제 실패 후 Stripe API를 통해 해당 인보이스를 직접 닫아야 합니다.

Payment Intent 생성

Billable 모델 인스턴스의 pay 메서드를 호출하면 새 Stripe Payment Intent를 생성할 수 있습니다. 이 메서드는 Laravel\Cashier\Payment 인스턴스로 래핑된 Payment Intent를 반환합니다.

use Illuminate\Http\Request; Route::post('/pay', function (Request $request) { $payment = $request->user()->pay( $request->get('amount') ); return $payment->client_secret; });

Payment Intent를 생성한 후, client_secret을 프론트엔드로 반환하면 사용자가 브라우저에서 결제를 완료할 수 있습니다. Stripe Payment Intent를 활용한 전체 결제 플로우 구현에 대한 자세한 내용은 Stripe 공식 문서를 참고하세요.

pay 메서드를 사용하면 Stripe 대시보드에서 활성화된 기본 결제 수단이 모두 고객에게 제공됩니다. 특정 결제 수단만 허용하고 싶다면 payWith 메서드를 사용하세요.

use Illuminate\Http\Request; Route::post('/pay', function (Request $request) { $payment = $request->user()->payWith( $request->get('amount'), ['card', 'bancontact'] ); return $payment->client_secret; });

WARNING

paypayWith 메서드도 마찬가지로, 애플리케이션에서 사용하는 통화의 최소 단위로 금액을 받습니다. 예를 들어 달러(USD)를 사용하는 경우 센트 단위로 지정해야 합니다.

결제 환불

Stripe 결제를 환불해야 하는 경우 refund 메서드를 사용하세요. 첫 번째 인수로 Stripe Payment Intent ID를 전달합니다.

$payment = $user->charge(100, $paymentMethodId); $user->refund($payment->id);

인보이스

인보이스 조회

청구 가능한 모델의 인보이스 목록은 invoices 메서드로 간단히 가져올 수 있습니다. 이 메서드는 Laravel\Cashier\Invoice 인스턴스의 컬렉션을 반환합니다:

$invoices = $user->invoices();

결제 대기 중인 인보이스까지 함께 조회하려면 invoicesIncludingPending 메서드를 사용하세요:

$invoices = $user->invoicesIncludingPending();

특정 인보이스를 ID로 조회할 때는 findInvoice 메서드를 사용합니다:

$invoice = $user->findInvoice($invoiceId);

인보이스 정보 표시

인보이스 목록을 화면에 출력할 때는 인보이스 객체의 메서드를 활용해 필요한 정보를 표시할 수 있습니다. 예를 들어 아래와 같이 테이블로 인보이스 목록을 나열하고, 각 항목에 다운로드 링크를 제공할 수 있습니다:

<table> @foreach ($invoices as $invoice) <tr> <td>{{ $invoice->date()->toFormattedDateString() }}</td> <td>{{ $invoice->total() }}</td> <td><a href="/user/invoice/{{ $invoice->id }}">다운로드</a></td> </tr> @endforeach </table>

예정 인보이스 조회

고객의 다음 청구 예정 인보이스는 upcomingInvoice 메서드로 조회합니다:

$invoice = $user->upcomingInvoice();

고객이 여러 구독을 보유한 경우, 특정 구독의 예정 인보이스만 별도로 조회할 수도 있습니다:

$invoice = $user->subscription('default')->upcomingInvoice();

구독 인보이스 미리보기

요금제를 변경하기 전에 previewInvoice 메서드를 사용하면 변경 후 청구 금액을 미리 확인할 수 있습니다. 고객에게 변경 내용을 안내하거나 확인 화면을 제공할 때 유용합니다:

$invoice = $user->subscription('default')->previewInvoice('price_yearly');

여러 요금제를 동시에 변경하는 경우에는 배열로 전달하면 됩니다:

$invoice = $user->subscription('default')->previewInvoice(['price_yearly', 'price_metered']);

인보이스 PDF 생성

인보이스 PDF를 생성하기 전에, Cashier의 기본 PDF 렌더러로 사용되는 Dompdf 라이브러리를 Composer로 먼저 설치해야 합니다:

composer require dompdf/dompdf

라우트나 컨트롤러에서 downloadInvoice 메서드를 호출하면 지정한 인보이스의 PDF를 자동으로 생성하고, 브라우저에서 바로 다운로드되도록 적절한 HTTP 응답을 반환합니다:

use Illuminate\Http\Request; Route::get('/user/invoice/{invoice}', function (Request $request, string $invoiceId) { return $request->user()->downloadInvoice($invoiceId); });

기본적으로 인보이스에 표시되는 정보는 Stripe에 저장된 고객 및 인보이스 데이터를 사용하며, 파일명은 app.name 설정값을 기반으로 자동 생성됩니다. 두 번째 인자로 배열을 전달하면 회사명, 상품명, 주소 등 표시 정보를 직접 지정할 수 있습니다:

return $request->user()->downloadInvoice($invoiceId, [ 'vendor' => '주식회사 예시', 'product' => '프리미엄 구독', 'street' => '테헤란로 1', 'location' => '서울특별시 강남구 06234', 'phone' => '02-1234-5678', 'email' => 'info@example.com', 'url' => 'https://example.com', 'vendorVat' => '123-45-67890', ]);

세 번째 인자로 파일명을 직접 지정할 수도 있습니다. 지정한 이름 뒤에는 자동으로 .pdf가 붙습니다:

return $request->user()->downloadInvoice($invoiceId, [], 'my-invoice');

커스텀 인보이스 렌더러

Cashier는 커스텀 인보이스 렌더러도 지원합니다. 기본값은 dompdf PHP 라이브러리를 사용하는 DompdfInvoiceRenderer이지만, Laravel\Cashier\Contracts\InvoiceRenderer 인터페이스를 구현하면 원하는 렌더러로 교체할 수 있습니다. 예를 들어, 외부 PDF 변환 API를 호출하는 렌더러를 아래와 같이 구현할 수 있습니다:

use Illuminate\Support\Facades\Http; use Laravel\Cashier\Contracts\InvoiceRenderer; use Laravel\Cashier\Invoice; class ApiInvoiceRenderer implements InvoiceRenderer { /** * 인보이스를 렌더링하고 PDF 바이너리를 반환합니다. */ public function render(Invoice $invoice, array $data = [], array $options = []): string { $html = $invoice->view($data)->render(); return Http::get('https://example.com/html-to-pdf', ['html' => $html])->get()->body(); } }

커스텀 렌더러를 구현한 뒤에는 config/cashier.php 설정 파일의 cashier.invoices.renderer 값을 해당 클래스명으로 변경하면 적용됩니다.

결제(Cashier)

Checkout

Cashier Stripe는 Stripe Checkout을 지원합니다. Stripe Checkout은 Stripe가 제공하는 사전 구축된 호스팅 결제 페이지로, 직접 결제 UI를 구현하는 수고를 덜어줍니다.

Stripe Checkout에 대한 더 자세한 내용은 Stripe 공식 Checkout 문서를 함께 참고하세요.

상품 Checkout

Stripe 대시보드에 이미 등록된 상품을 결제하려면 청구 가능한 모델의 checkout 메서드를 사용합니다. 이 메서드는 새로운 Stripe Checkout 세션을 시작합니다. 기본적으로 Stripe Price ID를 전달해야 합니다.

use Illuminate\Http\Request; Route::get('/product-checkout', function (Request $request) { return $request->user()->checkout('price_tshirt'); });

수량을 지정하려면 다음과 같이 배열로 전달합니다.

use Illuminate\Http\Request; Route::get('/product-checkout', function (Request $request) { return $request->user()->checkout(['price_tshirt' => 15]); });

이 라우트에 접근하면 사용자는 Stripe의 Checkout 페이지로 리디렉션됩니다. 결제 완료 또는 취소 시 기본적으로 home 라우트로 돌아오지만, success_urlcancel_url 옵션으로 직접 지정할 수도 있습니다.

use Illuminate\Http\Request; Route::get('/product-checkout', function (Request $request) { return $request->user()->checkout(['price_tshirt' => 1], [ 'success_url' => route('your-success-route'), 'cancel_url' => route('your-cancel-route'), ]); });

success_url에 Checkout 세션 ID를 쿼리 파라미터로 추가하고 싶다면 URL에 {CHECKOUT_SESSION_ID} 플레이스홀더를 사용하세요. Stripe가 자동으로 실제 세션 ID로 치환해 줍니다.

use Illuminate\Http\Request; use Stripe\Checkout\Session; use Stripe\Customer; Route::get('/product-checkout', function (Request $request) { return $request->user()->checkout(['price_tshirt' => 1], [ 'success_url' => route('checkout-success').'?session_id={CHECKOUT_SESSION_ID}', 'cancel_url' => route('checkout-cancel'), ]); }); Route::get('/checkout-success', function (Request $request) { $checkoutSession = $request->user()->stripe()->checkout->sessions->retrieve($request->get('session_id')); return view('checkout.success', ['checkoutSession' => $checkoutSession]); })->name('checkout-success');

프로모션 코드

Stripe Checkout은 기본적으로 사용자가 직접 입력하는 프로모션 코드를 허용하지 않습니다. allowPromotionCodes 메서드를 호출하면 Checkout 페이지에서 프로모션 코드 입력란을 활성화할 수 있습니다.

use Illuminate\Http\Request; Route::get('/product-checkout', function (Request $request) { return $request->user() ->allowPromotionCodes() ->checkout('price_tshirt'); });

단건 결제 Checkout

Stripe 대시보드에 등록하지 않은 임시 상품에 대해 단건 결제를 진행할 수도 있습니다. checkoutCharge 메서드에 금액(센트 단위), 상품명, 수량을 전달하면 됩니다.

use Illuminate\Http\Request; Route::get('/charge-checkout', function (Request $request) { return $request->user()->checkoutCharge(1200, 'T-Shirt', 5); });

WARNING

checkoutCharge 메서드를 사용하면 Stripe 대시보드에 새 상품과 가격이 매번 생성됩니다. 따라서 가능하면 Stripe 대시보드에서 상품을 미리 등록하고 checkout 메서드를 사용하는 것을 권장합니다.

구독 Checkout

WARNING

Stripe Checkout으로 구독을 시작하려면 Stripe 대시보드에서 customer.subscription.created 웹훅을 반드시 활성화해야 합니다. 이 웹훅이 데이터베이스에 구독 레코드를 생성하고 관련 구독 항목을 저장합니다.

Cashier의 구독 빌더 메서드로 구독을 정의한 후 checkout 메서드를 호출하면 Stripe Checkout을 통해 구독을 시작할 수 있습니다.

use Illuminate\Http\Request; Route::get('/subscription-checkout', function (Request $request) { return $request->user() ->newSubscription('default', 'price_monthly') ->checkout(); });

상품 Checkout과 마찬가지로 성공/취소 URL을 커스터마이즈할 수 있습니다.

use Illuminate\Http\Request; Route::get('/subscription-checkout', function (Request $request) { return $request->user() ->newSubscription('default', 'price_monthly') ->checkout([ 'success_url' => route('your-success-route'), 'cancel_url' => route('your-cancel-route'), ]); });

구독 Checkout에도 프로모션 코드를 활성화할 수 있습니다.

use Illuminate\Http\Request; Route::get('/subscription-checkout', function (Request $request) { return $request->user() ->newSubscription('default', 'price_monthly') ->allowPromotionCodes() ->checkout(); });

WARNING

Stripe Checkout은 구독 시작 시 모든 청구 옵션을 지원하지 않습니다. 구독 빌더에서 anchorBillingCycleOn, 비례 배분(proration) 동작 설정, 결제 동작 설정 등은 Stripe Checkout 세션 중에는 적용되지 않습니다. 사용 가능한 파라미터는 Stripe Checkout Session API 문서를 참고하세요.

Stripe Checkout과 무료 체험 기간

Stripe Checkout으로 완료되는 구독에도 무료 체험 기간을 설정할 수 있습니다.

$checkout = Auth::user()->newSubscription('default', 'price_monthly') ->trialDays(3) ->checkout();

단, 무료 체험 기간은 최소 48시간 이상이어야 합니다. 이는 Stripe Checkout이 지원하는 최소 체험 기간입니다.

구독과 웹훅

Stripe와 Cashier는 웹훅을 통해 구독 상태를 업데이트합니다. 따라서 사용자가 결제 정보를 입력하고 애플리케이션으로 돌아왔을 때 구독이 아직 활성화되지 않을 수 있습니다. 이런 경우를 대비해 결제 또는 구독이 처리 중임을 사용자에게 안내하는 메시지를 표시하는 것이 좋습니다.

세금 ID 수집

Checkout 세션에서 고객의 세금 ID(사업자등록번호 등)를 수집할 수 있습니다. 세션 생성 시 collectTaxIds 메서드를 호출하면 됩니다.

$checkout = $user->collectTaxIds()->checkout('price_tshirt');

이 메서드를 호출하면 Checkout 페이지에 체크박스가 추가되어, 고객이 기업으로 구매하는지 여부를 선택하고 세금 ID를 입력할 수 있게 됩니다.

WARNING

애플리케이션의 서비스 프로바이더에서 이미 자동 세금 수집을 설정한 경우, 이 기능은 자동으로 활성화되므로 collectTaxIds 메서드를 별도로 호출할 필요가 없습니다.

비회원 Checkout

계정이 없는 비회원 사용자를 위한 Checkout 세션은 Checkout::guest 메서드로 시작할 수 있습니다.

use Illuminate\Http\Request; use Laravel\Cashier\Checkout; Route::get('/product-checkout', function (Request $request) { return Checkout::guest()->create('price_tshirt', [ 'success_url' => route('your-success-route'), 'cancel_url' => route('your-cancel-route'), ]); });

회원 Checkout과 마찬가지로, Laravel\Cashier\CheckoutBuilder 인스턴스에서 사용 가능한 다양한 메서드로 비회원 Checkout 세션을 커스터마이즈할 수 있습니다.

use Illuminate\Http\Request; use Laravel\Cashier\Checkout; Route::get('/product-checkout', function (Request $request) { return Checkout::guest() ->withPromotionCode('promo-code') ->create('price_tshirt', [ 'success_url' => route('your-success-route'), 'cancel_url' => route('your-cancel-route'), ]); });

비회원 Checkout이 완료되면 Stripe에서 checkout.session.completed 웹훅 이벤트를 발송합니다. Stripe 웹훅 설정에서 이 이벤트가 애플리케이션으로 전달되도록 구성해야 합니다. 웹훅을 활성화한 후에는 Cashier로 웹훅 처리하기를 통해 이벤트를 처리할 수 있습니다. 웹훅 페이로드에는 checkout 객체가 포함되어 있으며, 이를 통해 주문 처리 로직을 구현할 수 있습니다.

결제(Cashier)

결제 실패 처리

구독 생성이나 단건 결제 시 결제가 실패하는 경우가 있습니다. 이때 Cashier는 Laravel\Cashier\Exceptions\IncompletePayment 예외를 발생시켜 상황을 알려줍니다. 이 예외를 잡은 후에는 두 가지 방법으로 처리할 수 있습니다.

첫 번째 방법은 Cashier에 내장된 전용 결제 확인 페이지로 사용자를 리다이렉트하는 것입니다. 이 페이지는 Cashier의 서비스 프로바이더가 자동으로 네임드 라우트를 등록해두기 때문에 별도 설정 없이 바로 사용할 수 있습니다.

use Laravel\Cashier\Exceptions\IncompletePayment; try { $subscription = $user->newSubscription('default', 'price_monthly') ->create($paymentMethod); } catch (IncompletePayment $exception) { return redirect()->route( 'cashier.payment', [$exception->payment->id, 'redirect' => route('home')] ); }

결제 확인 페이지에서 사용자는 카드 정보를 다시 입력하거나 "3D Secure" 인증처럼 Stripe가 요구하는 추가 작업을 수행하게 됩니다. 인증이 완료되면 위에서 redirect 파라미터로 지정한 URL로 이동하며, 이때 message(문자열)와 success(정수) 쿼리 파라미터가 URL에 자동으로 추가됩니다.

현재 결제 확인 페이지에서 지원하는 결제 수단은 다음과 같습니다.

  • 신용카드
  • Alipay
  • Bancontact
  • BECS Direct Debit
  • EPS
  • Giropay
  • iDEAL
  • SEPA Direct Debit

두 번째 방법은 Stripe의 자동 청구 이메일 기능을 활용하는 것입니다. Stripe 대시보드에서 자동 청구 이메일을 설정해두면, Stripe가 결제 확인 안내를 사용자에게 직접 이메일로 발송합니다. 이 방식을 사용하더라도, IncompletePayment 예외를 잡은 경우에는 사용자에게 "결제 확인 이메일이 발송될 예정입니다"라고 안내하는 것이 좋습니다.

IncompletePayment 예외가 발생할 수 있는 메서드는 다음과 같습니다.

  • Billable 트레이트를 사용하는 모델의 charge, invoiceFor, invoice 메서드
  • SubscriptionBuildercreate 메서드
  • SubscriptionSubscriptionItem 모델의 incrementAndInvoice, swapAndInvoice 메서드

기존 구독에 미완료 결제가 있는지 확인하려면 hasIncompletePayment 메서드를 사용하세요.

if ($user->hasIncompletePayment('default')) { // ... } if ($user->subscription('default')->hasIncompletePayment()) { // ... }

미완료 결제의 구체적인 상태는 예외 인스턴스의 payment 속성을 통해 확인할 수 있습니다.

use Laravel\Cashier\Exceptions\IncompletePayment; try { $user->charge(1000, 'pm_card_threeDSecure2Required'); } catch (IncompletePayment $exception) { // Payment Intent 상태 확인 $exception->payment->status; // 세부 조건 확인 if ($exception->payment->requiresPaymentMethod()) { // 결제 수단이 필요한 경우 } elseif ($exception->payment->requiresConfirmation()) { // 추가 인증이 필요한 경우 } }

결제 확인 (Confirming Payments)

일부 결제 수단은 결제를 완료하기 위해 추가 데이터가 필요합니다. 예를 들어 SEPA 결제 수단은 결제 과정에서 "mandate" 데이터를 요구합니다. 이런 추가 옵션은 withPaymentConfirmationOptions 메서드를 사용해 전달할 수 있습니다.

$subscription->withPaymentConfirmationOptions([ 'mandate_data' => '...', ])->swap('price_xxx');

결제 확인 시 사용할 수 있는 모든 옵션은 Stripe API 문서를 참고하세요.

결제(Cashier)

강력한 고객 인증 (Strong Customer Authentication)

사업체 또는 고객이 유럽에 위치해 있다면, EU의 강력한 고객 인증(SCA) 규정을 준수해야 합니다. 이 규정은 결제 사기를 방지하기 위해 2019년 9월 유럽연합이 도입한 것으로, Stripe과 Cashier는 이미 SCA 규정을 준수하는 애플리케이션 구축을 지원할 수 있도록 준비되어 있습니다.

WARNING

시작하기 전에 Stripe의 PSD2 및 SCA 가이드새로운 SCA API 문서를 반드시 먼저 검토하시기 바랍니다.

추가 확인이 필요한 결제

SCA 규정에 따라 결제를 승인하고 처리하기 위해 추가적인 본인 인증이 요구되는 경우가 있습니다. 이런 상황이 발생하면 Cashier는 Laravel\Cashier\Exceptions\IncompletePayment 예외를 던져 추가 인증이 필요함을 알립니다. 이 예외를 처리하는 방법은 실패한 결제 처리 문서를 참고하세요.

Stripe 또는 Cashier가 표시하는 결제 확인 화면은 특정 은행이나 카드사의 결제 흐름에 맞춰 달라질 수 있으며, 추가 카드 인증, 소액 임시 청구, 별도 기기 인증 등 다양한 인증 방식이 포함될 수 있습니다.

미완료(Incomplete) 및 연체(Past Due) 상태

결제에 추가 확인이 필요한 경우, 구독은 stripe_status 데이터베이스 컬럼에 incomplete 또는 past_due 상태로 유지됩니다. 결제 확인이 완료되고 Stripe이 웹훅을 통해 애플리케이션에 완료 사실을 알리면, Cashier가 자동으로 해당 고객의 구독을 활성화합니다.

incompletepast_due 상태에 대한 자세한 내용은 해당 상태에 관한 추가 문서를 참고하세요.

세션 외 결제 알림 (Off-Session Payment Notifications)

SCA 규정에 따르면, 구독이 활성 상태인 경우에도 고객이 주기적으로 결제 정보를 인증해야 할 수 있습니다. 예를 들어 구독이 갱신될 때 세션 외(off-session) 결제 확인이 요구될 수 있는데, Cashier는 이런 상황에서 고객에게 알림을 발송하는 기능을 제공합니다.

이 기능을 활성화하려면 CASHIER_PAYMENT_NOTIFICATION 환경 변수에 알림 클래스를 지정하면 됩니다. 기본적으로 이 알림은 비활성화되어 있습니다. Cashier에 기본 제공되는 알림 클래스를 사용할 수도 있고, 필요에 따라 직접 알림 클래스를 작성하여 지정할 수도 있습니다:

CASHIER_PAYMENT_NOTIFICATION=Laravel\Cashier\Notifications\ConfirmPayment

세션 외 결제 확인 알림이 정상적으로 전달되려면 다음 두 가지를 확인해야 합니다:

  1. 애플리케이션에 Stripe 웹훅이 설정되어 있고, Stripe 대시보드에서 invoice.payment_action_required 웹훅이 활성화되어 있어야 합니다.
  2. Billable 모델에 Laravel의 Illuminate\Notifications\Notifiable 트레이트가 적용되어 있어야 합니다.

WARNING

고객이 추가 확인이 필요한 결제를 직접 수동으로 진행하는 경우에도 알림이 발송됩니다. 안타깝게도 Stripe은 해당 결제가 수동으로 이루어진 것인지, 세션 외에서 발생한 것인지 구분할 방법이 없습니다. 다만, 고객이 이미 결제를 확인한 후 결제 페이지를 다시 방문하면 단순히 "결제 성공" 메시지만 표시될 뿐이며, 실수로 동일한 결제를 두 번 확인하거나 중복 청구가 발생하는 일은 없습니다.

결제(Cashier)

Stripe SDK

Cashier의 많은 객체는 Stripe SDK 객체를 감싸는 래퍼(wrapper)입니다. Stripe 객체에 직접 접근하고 싶다면 asStripe 메서드를 사용하면 편리합니다.

$stripeSubscription = $subscription->asStripeSubscription(); $stripeSubscription->application_fee_percent = 5; $stripeSubscription->save();

Stripe 구독을 직접 수정하고 싶다면 updateStripeSubscription 메서드를 사용할 수도 있습니다.

$subscription->updateStripeSubscription(['application_fee_percent' => 5]);

Stripe\StripeClient 클라이언트를 직접 사용하고 싶다면 Cashier 클래스의 stripe 메서드를 호출하면 됩니다. 예를 들어, 아래와 같이 StripeClient 인스턴스를 통해 Stripe 계정의 가격 목록을 조회할 수 있습니다.

use Laravel\Cashier\Cashier; $prices = Cashier::stripe()->prices->all();

결제(Cashier) — 테스트

테스트

Cashier를 사용하는 애플리케이션을 테스트할 때, Stripe API로 전송되는 실제 HTTP 요청을 모킹(mock)하는 방법을 생각할 수 있습니다. 그러나 이 방식은 Cashier 내부 동작을 부분적으로 직접 재구현해야 하므로 권장하지 않습니다.

대신, 실제 Stripe 테스트 환경 API를 호출하는 방식을 권장합니다. 테스트 속도는 다소 느릴 수 있지만, 애플리케이션이 실제로 의도한 대로 동작하는지 훨씬 높은 신뢰도로 확인할 수 있습니다. 속도가 느린 테스트는 별도의 Pest / PHPUnit 테스트 그룹으로 분리해 관리하면 됩니다.

또한, Cashier 자체는 이미 충실한 테스트 스위트를 갖추고 있습니다. 따라서 Cashier의 내부 동작 하나하나를 검증하려 하지 말고, 여러분의 애플리케이션에 특화된 구독 및 결제 흐름에 집중해 테스트를 작성하세요.


설정

시작하려면, Stripe 테스트용 시크릿 키를 phpunit.xml 파일에 추가합니다.

<env name="STRIPE_SECRET" value="sk_test_<your-key>"/>

이렇게 설정하면 테스트 실행 시 Cashier가 Stripe 테스트 환경으로 실제 API 요청을 전송합니다.

편의를 위해, 테스트에서 사용할 구독 플랜(상품/가격)을 Stripe 테스트 계정 대시보드에 미리 등록해 두는 것이 좋습니다. 테스트 코드에서 해당 Price ID를 그대로 참조할 수 있어 설정이 간결해집니다.

NOTE

카드 거절, 결제 실패 등 다양한 결제 시나리오를 테스트하려면 Stripe가 제공하는 테스트용 카드 번호 및 토큰을 활용하세요. 예를 들어, 4000000000000002는 항상 카드 거절을 반환하는 번호입니다.

이 문서는 Laravel 공식 문서(MIT)를 한국 개발자를 위해 번역·재구성한 것입니다.

번역일: 2026년 7월 28일