본문 바로가기

Laravel Cashier (Paddle)

업데이트됨

번역일: 2026년 7월 28일

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

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

Laravel Cashier (Paddle)

소개

Laravel Cashier PaddlePaddle의 구독 결제 서비스를 Laravel에서 손쉽게 사용할 수 있도록 해주는 공식 패키지입니다. 반복적으로 작성하기 번거로운 구독 결제 관련 코드 대부분을 Cashier가 대신 처리해 줍니다.

NOTE

일회성 결제만 필요하고 구독 기능은 사용하지 않는다면, Paddle 대신 Stripe를 연동하는 Cashier를 사용하는 것도 고려해 보세요.

Cashier 업그레이드

Cashier를 새 버전으로 업그레이드할 때는 반드시 업그레이드 가이드를 먼저 확인하세요.

설치

Composer로 패키지를 설치합니다:

composer require laravel/cashier-paddle

이어서 vendor:publish Artisan 명령어로 Cashier의 마이그레이션 파일을 프로젝트에 배포합니다:

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

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

php artisan migrate

Cashier 마이그레이션을 실행하면 customers, subscriptions, subscription_items, transactions 테이블이 생성됩니다.

Paddle 샌드박스

개발 및 테스트 단계에서는 Paddle 샌드박스 계정을 사용하세요. 샌드박스 환경에서는 실제 결제가 발생하지 않으므로 안전하게 기능을 검증할 수 있습니다. Paddle에서 제공하는 테스트 카드 번호를 활용해 다양한 결제 시나리오를 테스트할 수 있습니다.

샌드박스 환경을 사용할 때는 .env 파일에서 PADDLE_SANDBOX 환경 변수를 true로 설정합니다:

PADDLE_SANDBOX=true

개발이 완료된 후 Paddle 정식 벤더 계정을 신청하고, 프로덕션 배포 시 PADDLE_SANDBOXfalse로 변경하면 됩니다.

설정

Billable 모델

Cashier를 사용하기 전에, 결제 대상이 되는 모델(일반적으로 User 모델)에 Billable 트레이트를 추가해야 합니다. 이 트레이트가 구독 생성, 인보이스 조회 등 결제 관련 핵심 기능을 제공합니다:

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

User 모델이 아닌 다른 엔티티(예: 팀, 조직 등)를 결제 주체로 사용해야 한다면, 해당 모델에도 동일하게 Billable 트레이트를 추가하면 됩니다.

API 키

Paddle 대시보드에서 발급받은 API 키를 .env 파일에 설정합니다:

PADDLE_CLIENT_SIDE_TOKEN=your-paddle-client-side-token PADDLE_API_KEY=your-paddle-api-key PADDLE_RETAIN_KEY=your-paddle-retain-key PADDLE_WEBHOOK_SECRET="your-paddle-webhook-secret" PADDLE_SANDBOX=true

PADDLE_SANDBOXtrue로 설정하면 샌드박스 환경으로 동작합니다. 프로덕션 배포 시에는 false로 변경하세요.

WARNING

PADDLE_RETAIN_KEY는 Paddle의 Retain(해지 방지) 기능을 사용하는 경우에만 설정이 필요합니다. 사용하지 않는다면 생략해도 됩니다.

Paddle JS

Paddle은 체크아웃 위젯을 표시하기 위해 자체 JavaScript 라이브러리를 사용합니다. 애플리케이션의 레이아웃 파일 </head> 닫는 태그 직전에 @paddleJS Blade 디렉티브를 추가하면 라이브러리가 자동으로 로드됩니다:

<head> ... @paddleJS </head>

통화 설정

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

CASHIER_CURRENCY_LOCALE=ko_KR

WARNING

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

기본 모델 재정의

Cashier가 내부적으로 사용하는 모델을 직접 확장할 수 있습니다. 예를 들어 Subscription 모델에 커스텀 메서드를 추가하고 싶다면 다음과 같이 하세요:

use Laravel\Paddle\Subscription as CashierSubscription; class Subscription extends CashierSubscription { // 커스텀 메서드 추가 }

모델을 정의한 후, Laravel\Paddle\Cashier 클래스를 통해 Cashier가 해당 모델을 사용하도록 등록합니다. 일반적으로 애플리케이션의 서비스 프로바이더 boot 메서드에서 설정합니다:

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

빠른 시작

단일 상품 판매

NOTE

Paddle 체크아웃을 사용하기 전에 Paddle 대시보드에서 고정 가격으로 상품을 먼저 등록해 두어야 합니다.

애플리케이션에서 단일 상품 또는 구독을 판매하려면 Paddle의 체크아웃 기능을 활용합니다. 아래는 "구매" 버튼 클릭 시 체크아웃 창을 띄우는 간단한 예시입니다:

<x-paddle-button :url="$payLink" class="px-8 py-4"> 구매하기 </x-paddle-button>

체크아웃 버튼에 연결할 결제 링크를 컨트롤러에서 생성합니다:

use Illuminate\Http\Request; Route::get('/buy', function (Request $request) { $checkout = $request->user()->checkout('pri_deluxe_monthly') ->returnTo(route('dashboard')); return view('buy', ['checkout' => $checkout]); });

Cashier는 checkout 메서드를 통해 지정된 "가격 식별자(price identifier)"로 체크아웃 세션을 생성합니다. 결제 완료 후 사용자가 돌아올 URL은 returnTo 메서드로 지정합니다.

필요하다면 checkoutCharge 메서드로 체크아웃 버튼을 생성할 수도 있습니다:

<x-paddle-button :url="$payLink" class="px-8 py-4"> 구매하기 </x-paddle-button>

체크아웃이 완료되면 Paddle이 transaction.completed 웹훅을 전송합니다. 자세한 내용은 웹훅 처리 섹션을 참고하세요.

구독 판매

NOTE

Paddle 체크아웃을 사용하기 전에 Paddle 대시보드에서 고정 가격으로 상품을 먼저 등록해 두어야 합니다. 또한 Paddle 웹훅 처리도 설정해야 합니다.

애플리케이션에서 구독을 판매하는 방법을 알아봅시다. 사용자가 구독 플랜을 선택하고 결제할 수 있는 간단한 예시입니다.

먼저 라우트에서 체크아웃 세션을 생성합니다:

use Illuminate\Http\Request; Route::get('/subscribe', function (Request $request) { $checkout = $request->user()->subscribe('pri_monthly') ->returnTo(route('dashboard')); return view('subscribe', ['checkout' => $checkout]); });

Cashier가 제공하는 <x-paddle-button> 컴포넌트를 뷰에서 사용하면 됩니다:

<x-paddle-button :url="$checkout" class="px-8 py-4"> 구독하기 </x-paddle-button>

구독이 완료되면 Paddle이 웹훅을 전송하고, Cashier가 이를 받아 데이터베이스에 구독 정보를 저장합니다. 구독 상태 확인은 다음과 같이 할 수 있습니다:

if ($user->subscribed()) { // 구독 중인 사용자 }

특정 플랜에 구독 중인지 확인하려면:

if ($user->subscribedToPrice('pri_monthly')) { // 월간 플랜 구독 중 }

NOTE

Paddle 웹훅이 정상적으로 처리되어야 구독 정보가 데이터베이스에 저장됩니다. 로컬 개발 환경에서는 웹훅 처리 섹션의 안내에 따라 웹훅 수신 환경을 별도로 구성해야 합니다.

체크아웃 세션

대부분의 결제 작업은 Paddle의 체크아웃 위젯을 통해 이루어집니다. Paddle에서 체크아웃을 직접 처리하므로, 애플리케이션 서버에서 결제 정보를 직접 다루지 않아도 됩니다.

오버레이 체크아웃

오버레이 체크아웃은 페이지 위에 체크아웃 위젯이 팝업 형태로 표시되는 방식입니다. Cashier에서 제공하는 <x-paddle-button> Blade 컴포넌트를 사용하면 됩니다:

<x-paddle-button :url="$checkout" class="px-8 py-4"> 구독하기 </x-paddle-button>

이 컴포넌트를 초기화하려면 체크아웃 세션을 생성해야 합니다. 아래 예시처럼 라우트에서 체크아웃 세션을 만들어 뷰에 전달하세요:

use Illuminate\Http\Request; Route::get('/subscribe', function (Request $request) { $checkout = $request->user()->subscribe('pri_monthly') ->returnTo(route('dashboard')); return view('subscribe', ['checkout' => $checkout]); });

WARNING

체크아웃 컴포넌트를 사용하려면 레이아웃에 @paddleJS 디렉티브가 포함되어 있어야 합니다. Paddle JS 섹션을 참고하세요.

버튼이 아닌 다른 HTML 요소에 체크아웃 링크를 직접 연결하려면 <x-paddle-checkout> 컴포넌트를 사용할 수도 있습니다:

<x-paddle-checkout :url="$checkout" class="w-full" />

인라인 체크아웃

팝업 대신 페이지 내에 체크아웃 위젯을 직접 삽입하고 싶다면 인라인 체크아웃을 사용하세요. <x-paddle-checkout> 컴포넌트에 inline 속성을 추가하면 됩니다:

<x-paddle-checkout :url="$checkout" class="w-full" inline />

비회원 체크아웃

로그인하지 않은 사용자에게도 체크아웃 기능을 제공할 수 있습니다. guest 메서드를 사용하면 됩니다:

use Illuminate\Http\Request; use Laravel\Paddle\Checkout; Route::get('/buy', function (Request $request) { $checkout = Checkout::guest('pri_deluxe_monthly') ->returnTo(route('home')); return view('buy', ['checkout' => $checkout]); });

이후 오버레이 또는 인라인 체크아웃 방식에 동일하게 사용할 수 있습니다.

가격 미리보기

Paddle은 통화별로 다른 가격을 설정할 수 있으며, 국가별로 적절한 가격을 표시하는 기능을 제공합니다. Cashier의 previewPrices 메서드로 가격 정보를 가져올 수 있습니다:

use Laravel\Paddle\Cashier; $prices = Cashier::previewPrices(['pri_standard_monthly', 'pri_premium_monthly']);

통화는 요청의 IP 주소를 기반으로 자동으로 결정됩니다. 특정 국가의 가격을 조회하려면 다음과 같이 국가 코드를 지정하세요:

use Laravel\Paddle\Cashier; $prices = Cashier::previewPrices(['pri_standard_monthly', 'pri_premium_monthly'], ['address' => [ 'country_code' => 'KR', 'postal_code' => '06000', ]]);

가격 정보를 조회한 후에는 뷰에서 자유롭게 표시할 수 있습니다:

<ul> @foreach ($prices as $price) <li>{{ $price->product['name'] }} - {{ $price->total() }}</li> @endforeach </ul>

소계(세금 제외)와 세액도 별도로 표시할 수 있습니다:

<ul> @foreach ($prices as $price) <li>{{ $price->product['name'] }} - {{ $price->subtotal() }} + {{ $price->tax() }} 세금</li> @endforeach </ul>

자세한 내용은 Paddle의 가격 미리보기 API 문서를 참고하세요.

고객별 가격 미리보기

이미 Paddle에 등록된 고객이라면 해당 고객에게 적용되는 가격을 직접 조회할 수 있습니다:

use Illuminate\Http\Request; Route::get('/prices', function (Request $request) { $prices = $request->user()->previewPrices(['pri_standard_monthly', 'pri_premium_monthly']); // ... });

할인

할인이 적용된 가격도 미리 표시할 수 있습니다. previewPrices 메서드에 할인 ID를 전달하면 됩니다:

use Laravel\Paddle\Cashier; $prices = Cashier::previewPrices( ['pri_standard_monthly', 'pri_premium_monthly'], ['discount_id' => 'dsc_XXXXXXXX'] );

계산된 가격을 뷰에서 표시합니다:

<ul> @foreach ($prices as $price) <li>{{ $price->product['name'] }} - {{ $price->total() }}</li> @endforeach </ul>

고객

고객 기본값

Cashier를 사용하면 체크아웃 세션 생성 시 고객 정보를 미리 채워줄 수 있습니다. 기본값을 설정해 두면 사용자가 결제 시 이메일과 이름을 다시 입력하지 않아도 됩니다. Billable 모델에 다음 메서드를 재정의하면 됩니다:

/** * Paddle 고객과 연결할 이메일 주소를 반환합니다. */ public function paddleEmail(): string|null { return $this->email; }

이 외에도 paddleName, paddleLocale 메서드를 재정의할 수 있습니다:

public function paddleName(): string|null { return $this->name; } public function paddleLocale(): string|null { return 'ko'; }

고객 조회

Paddle 고객 ID로 고객 정보를 조회하려면 Cashier::findBillable 메서드를 사용합니다:

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

고객 생성

구독이나 결제 없이 Paddle에 고객을 먼저 등록해야 하는 경우 createAsCustomer 메서드를 사용합니다:

$customer = $user->createAsCustomer();

Laravel\Paddle\Customer 인스턴스가 반환됩니다. Paddle에 고객이 등록된 후 언제든지 구독을 시작할 수 있습니다. Paddle API에서 지원하는 추가 파라미터도 전달할 수 있습니다:

$customer = $user->createAsCustomer($options);

구독

구독 생성

구독을 시작하려면 먼저 Billable 모델 인스턴스를 가져옵니다. 보통 인증된 사용자를 통해 접근합니다. 이후 subscribe 메서드로 체크아웃 세션을 생성합니다:

use Illuminate\Http\Request; Route::get('/subscribe', function (Request $request) { $checkout = $request->user()->subscribe('pri_monthly') ->returnTo(route('dashboard')); return view('subscribe', ['checkout' => $checkout]); });

사용자가 체크아웃을 완료하면 Paddle이 subscription.created 웹훅을 전송하고, Cashier가 이를 처리해 데이터베이스에 구독 정보를 저장합니다.

구독 상태 확인

사용자가 구독 중인지 확인하는 가장 기본적인 방법은 subscribed 메서드입니다:

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

이 메서드는 평가판(trial) 기간을 포함해 구독이 활성 상태이면 true를 반환합니다. 미들웨어에서 활용하면 구독 여부에 따라 라우트 접근을 제어할 수 있습니다:

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

현재 평가판 기간 중인지 확인하려면 onTrial 메서드를 사용합니다:

if ($user->onTrial()) { // 평가판 기간 중 }

특정 Paddle 가격 ID에 구독 중인지 확인하려면 subscribedToPrice 메서드를 사용합니다:

if ($user->subscribedToPrice('pri_monthly')) { // ... }

특정 구독이 활성 상태인지 확인하려면 subscription 메서드로 구독 인스턴스를 가져온 후 상태를 확인합니다:

$subscription = $user->subscription(); if ($subscription->active()) { // ... }

사용 가능한 구독 상태 메서드는 다음과 같습니다:

메서드설명
active()구독이 활성 상태 (평가판 포함)
onTrial()현재 평가판 기간 중
paused()구독이 일시 정지됨
canceled()구독이 취소됨
pastDue()결제 기한 초과
trialing()구독 없이 평가판만 진행 중

구독 단건 청구

구독 주기 외에 추가 비용을 한 번만 청구하려면 charge 메서드를 사용합니다:

$response = $user->subscription()->charge('pri_addon');

결제 정보 업데이트

Paddle은 결제 정보를 항상 Paddle에서 직접 관리합니다. 사용자가 결제 정보를 변경하려면 redirectToUpdatePaymentMethod 메서드로 Paddle에서 제공하는 업데이트 페이지로 이동시킵니다:

return redirect($user->subscription()->redirectToUpdatePaymentMethod());

결제 정보 업데이트가 완료되면 Paddle이 subscription.updated 웹훅을 전송하고, Cashier가 구독 정보를 갱신합니다.

플랜 변경

구독 중인 플랜을 변경하려면 swap 메서드에 새 플랜의 Paddle 가격 ID를 전달합니다:

$user->subscription()->swap('pri_premium_monthly');

즉시 청구하지 않고 다음 갱신 시점에 플랜을 변경하려면 swapAndInvoice 대신 swap 메서드를 사용하면 됩니다. 반대로 플랜 변경과 동시에 즉시 청구하려면:

$user->subscription()->swapAndInvoice('pri_premium_monthly');

구독 수량

구독 수량을 변경해야 한다면 incrementQuantity, decrementQuantity, updateQuantity 메서드를 사용합니다:

$user->subscription()->incrementQuantity(); // 수량 5 증가 $user->subscription()->incrementQuantity(5); $user->subscription()->decrementQuantity(); // 수량 5 감소 $user->subscription()->decrementQuantity(5); // 수량을 특정 값으로 설정 $user->subscription()->updateQuantity(10);

여러 상품이 포함된 구독

여러 상품이 포함된 구독을 사용하면 하나의 구독에 여러 결제 상품을 포함할 수 있습니다. 예를 들어 기본 구독에 애드온을 추가하는 방식입니다.

구독 아이템을 조회하려면:

$subscription = $user->subscription(); $subscriptionItems = $subscription->items;

특정 가격이 구독에 포함되어 있는지 확인하려면:

if ($subscription->hasPrice('pri_addon')) { // ... }

다중 구독

한 사용자가 여러 구독을 동시에 가질 수 있습니다. 예를 들어 SaaS 서비스에서 여러 플랜을 제공하는 경우입니다. 구독을 구분하려면 subscribe 메서드의 두 번째 인수로 구독 타입 이름을 지정합니다:

// 기본 구독 $checkout = $user->subscribe('pri_standard', 'default')->returnTo(route('dashboard')); // 프리미엄 구독 $checkout = $user->subscribe('pri_premium', 'premium')->returnTo(route('dashboard'));

특정 구독 타입을 조회하려면:

if ($user->subscribed('premium')) { // 프리미엄 구독 중 }

구독 일시 정지

구독을 일시 정지하려면 구독 인스턴스에서 pause 메서드를 호출합니다:

$user->subscription()->pause();

일시 정지 중인 구독인지 확인하려면:

if ($user->subscription()->paused()) { // ... }

구독이 일시 정지 상태로 전환될 예정인 경우를 확인하려면:

if ($user->subscription()->onPausedGracePeriod()) { // ... }

구독을 재개하려면:

$user->subscription()->unpause();

구독 취소

구독을 취소하려면 cancel 메서드를 호출합니다:

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

구독을 취소하면 현재 청구 기간이 끝날 때까지 서비스를 계속 이용할 수 있습니다(유예 기간). 유예 기간 중인지 확인하려면:

if ($user->subscription()->onGracePeriod()) { // 유예 기간 중 (취소했지만 아직 만료 전) }

구독 취소를 철회(복원)하려면:

$user->subscription()->stopCancelation();

NOTE

Paddle 구독은 즉시 취소할 수 없습니다. 현재 청구 기간 종료 후 최종 취소됩니다.

구독 평가판

결제 수단 등록 후 평가판 시작

결제 수단을 미리 등록받고 평가판을 시작하려면 Paddle 대시보드에서 해당 가격에 평가판 기간을 설정한 후, 일반적인 체크아웃과 동일하게 진행하면 됩니다:

$checkout = $user->subscribe('pri_monthly_with_trial')->returnTo(route('dashboard'));

평가판이 시작되면 subscribed 메서드는 여전히 true를 반환하며, onTrial로 평가판 여부를 구분할 수 있습니다:

if ($user->onTrial()) { // 평가판 기간 중 }

결제 수단 없이 평가판 시작

결제 수단 등록 없이 평가판을 제공하려면 사용자 모델의 trialUntil 필드를 직접 설정합니다:

$user->forceFill([ 'trial_ends_at' => now()->addDays(14), ])->save();

이 방식을 제네릭 평가판이라 부릅니다. 평가판 중인지 확인하려면:

if ($user->onGenericTrial()) { // 제네릭 평가판 기간 중 }

실제 구독을 시작할 준비가 되면 일반적인 방식으로 체크아웃을 진행하면 됩니다.

평가판 연장 또는 활성화

기존 구독의 평가판을 연장하거나 즉시 활성화하려면 다음 메서드를 사용합니다:

// 특정 날짜까지 평가판 연장 $user->subscription()->extendTrial(now()->addDays(7)); // 평가판을 즉시 종료하고 정규 구독 활성화 $user->subscription()->activateTrial();

Paddle 웹훅 처리

Paddle은 결제, 구독 상태 변경 등 다양한 이벤트 발생 시 웹훅을 전송합니다. Cashier는 이 웹훅을 자동으로 처리하는 컨트롤러를 내장하고 있습니다.

웹훅을 수신하려면 먼저 라우트를 등록해야 합니다. Cashier는 routes/web.php에서 사용할 수 있는 편의 메서드를 제공합니다:

use Laravel\Paddle\Cashier; Cashier::routes();

그 다음 Paddle 대시보드에서 웹훅 URL을 https://your-app.com/paddle/webhook 으로 등록합니다.

WARNING

Cashier 웹훅 라우트는 CSRF 미들웨어에서 제외해야 합니다. bootstrap/app.php 파일에서 paddle/* 경로를 CSRF 검사에서 제외하세요:

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

웹훅 이벤트 핸들러 정의

Cashier는 결제 실패나 구독 변경 등의 웹훅 이벤트를 자동으로 처리합니다. 추가로 처리하고 싶은 이벤트가 있다면 Cashier가 발행하는 다음 이벤트들을 리스닝하면 됩니다:

Cashier 이벤트설명
Laravel\Paddle\Events\WebhookReceived웹훅 수신 시
Laravel\Paddle\Events\WebhookHandled웹훅 처리 완료 시

두 이벤트 모두 Paddle 웹훅의 전체 페이로드를 포함합니다. 예를 들어 transaction.billed 웹훅을 처리하려면:

<?php namespace App\Listeners; use Laravel\Paddle\Events\WebhookReceived; class PaddleEventListener { /** * Paddle 웹훅 이벤트를 처리합니다. */ public function handle(WebhookReceived $event): void { if ($event->payload['event_type'] === 'transaction.billed') { // 이벤트 처리 로직 } } }

리스너를 정의한 후 EventServiceProviderAppServiceProvider에 등록합니다:

use App\Listeners\PaddleEventListener; use Laravel\Paddle\Events\WebhookReceived; protected $listen = [ WebhookReceived::class => [ PaddleEventListener::class, ], ];

웹훅 서명 검증

웹훅을 안전하게 처리하기 위해 Paddle의 웹훅 서명을 검증할 수 있습니다. .env 파일에 PADDLE_WEBHOOK_SECRET을 설정하면 Cashier가 자동으로 서명을 검증합니다:

PADDLE_WEBHOOK_SECRET="your-paddle-webhook-secret"

웹훅 시크릿은 Paddle 대시보드의 Notifications 설정에서 확인할 수 있습니다.

단건 결제

상품 결제

구독 없이 단건으로 상품을 결제하려면 checkout 메서드를 사용합니다:

use Illuminate\Http\Request; Route::get('/buy', function (Request $request) { $checkout = $request->user()->checkout('pri_deluxe_monthly') ->returnTo(route('dashboard')); return view('buy', ['checkout' => $checkout]); });

비회원 체크아웃의 경우 Checkout::guest 메서드를 사용합니다:

use Laravel\Paddle\Checkout; $checkout = Checkout::guest('pri_deluxe_monthly') ->returnTo(route('home'));

트랜잭션 환불

트랜잭션을 환불하려면 Transaction 모델의 refund 메서드를 사용합니다:

$transaction = $user->transactions()->first(); $refund = $transaction->refund();

부분 환불을 처리하려면 환불할 금액과 이유를 지정할 수 있습니다:

$refund = $transaction->refund([ [ 'priceId' => 'pri_deluxe_monthly', 'quantity' => 1, ], ], '상품 불만족');

트랜잭션 크레딧 처리

환불 대신 고객에게 크레딧을 제공하려면 credit 메서드를 사용합니다:

$transaction = $user->transactions()->first(); $credit = $transaction->credit();

트랜잭션

Billable 모델의 트랜잭션 목록은 transactions 메서드로 쉽게 가져올 수 있습니다:

$transactions = $user->transactions();

트랜잭션을 뷰에서 표시하는 예시입니다:

<ul> @foreach ($transactions as $transaction) <li> {{ $transaction->billed_at->toFormattedDateString() }} - {{ $transaction->total() }} - {{ $transaction->tax() }} 세금 <a href="{{ route('download-invoice', $transaction->id) }}">인보이스 다운로드</a> </li> @endforeach </ul>

인보이스 PDF를 다운로드하는 라우트 예시입니다:

use Illuminate\Http\Request; use Laravel\Paddle\Transaction; Route::get('/download-invoice/{transaction}', function (Request $request, Transaction $transaction) { return $transaction->redirectToInvoicePdf(); })->name('download-invoice');

과거 및 예정 결제

정기 구독의 과거 결제 내역과 다음 예정 결제 정보를 조회하려면 다음 메서드를 사용합니다:

$subscription = $user->subscription(); // 가장 최근 결제 정보 $lastPayment = $subscription->lastPayment(); // 다음 예정 결제 정보 $nextPayment = $subscription->nextPayment();

두 메서드 모두 Laravel\Paddle\Payment 인스턴스를 반환하며, amountdate 속성으로 금액과 날짜를 확인할 수 있습니다:

<p>다음 결제: {{ $nextPayment->amount() }} ({{ $nextPayment->date()->toFormattedDateString() }})</p>

구독이 취소된 경우 nextPaymentnull을 반환합니다.

테스트

테스트 시에는 Cashier의 HTTP 요청을 직접 처리해서 실제 Paddle API를 호출하지 않고도 구매 흐름을 검증해야 합니다.

Cashier의 자체 테스트는 실제 Paddle API를 호출하므로, 통합 테스트를 작성하려면 Paddle 샌드박스 환경을 활용하세요. 느린 편이지만 실제 결제 흐름 전체를 검증할 수 있습니다.

단위 테스트에서는 Cashier와 관련된 서비스를 직접 목(mock) 처리하거나, 구독 상태 등 관련 모델 데이터를 직접 채워 테스트하는 방식을 권장합니다:

use App\Models\User; use Laravel\Paddle\Subscription; $user = User::factory()->create(); $user->subscriptions()->create([ 'name' => 'default', 'paddle_id' => 'sub_test_'.fake()->uuid(), 'paddle_status' => 'active', 'paddle_price_id' => 'pri_monthly', 'quantity' => 1, 'trial_ends_at' => null, 'paused_at' => null, 'ends_at' => null, ]); $this->assertTrue($user->subscribed());

이처럼 구독 레코드를 직접 생성하면 Paddle API 호출 없이도 구독 상태를 확인하는 로직을 테스트할 수 있습니다.

Cashier (Paddle)

소개

WARNING

이 문서는 Paddle Billing과 연동하는 Cashier Paddle 2.x를 기준으로 작성되었습니다. 아직 Paddle Classic을 사용 중이라면 Cashier Paddle 1.x 문서를 참고하세요.

Laravel Cashier PaddlePaddle의 구독 결제 서비스를 간결하고 표현력 있는 인터페이스로 제공합니다. 구독 결제에 필요한 반복적인 보일러플레이트 코드 대부분을 Cashier가 대신 처리해 줍니다. 기본적인 구독 관리 외에도 구독 변경(swap), 구독 수량(quantity) 관리, 구독 일시 정지, 해지 유예 기간(grace period) 등 다양한 기능을 지원합니다.

Cashier Paddle을 본격적으로 사용하기 전에 Paddle의 개념 가이드API 문서를 함께 살펴볼 것을 권장합니다.

Cashier (Paddle)

업그레이드

Cashier를 새 버전으로 업그레이드할 때는 반드시 업그레이드 가이드를 꼼꼼히 확인하세요.

Cashier (Paddle)

설치

Composer 패키지 매니저를 사용하여 Paddle용 Cashier 패키지를 설치합니다:

composer require laravel/cashier-paddle

다음으로, vendor:publish Artisan 명령어를 사용하여 Cashier 마이그레이션 파일을 퍼블리시합니다:

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

그런 다음 데이터베이스 마이그레이션을 실행합니다. Cashier 마이그레이션은 다음 테이블들을 생성합니다:

  • customers — 고객 정보 저장
  • subscriptions, subscription_items — 고객의 구독 정보 저장
  • transactions — 고객과 연결된 모든 Paddle 거래 내역 저장
php artisan migrate

WARNING

Cashier가 Paddle 이벤트를 올바르게 처리하려면 반드시 Cashier 웹훅 처리를 설정해야 합니다.

Paddle 샌드박스

로컬 및 스테이징 개발 환경에서는 Paddle 샌드박스 계정을 등록하여 사용하세요. 샌드박스 환경에서는 실제 결제 없이 애플리케이션을 자유롭게 테스트하고 개발할 수 있습니다. Paddle의 테스트 카드 번호를 이용하면 다양한 결제 시나리오를 시뮬레이션할 수 있습니다.

Paddle 샌드박스 환경을 사용할 때는 애플리케이션의 .env 파일에서 PADDLE_SANDBOX 환경 변수를 true로 설정하세요:

PADDLE_SANDBOX=true

애플리케이션 개발이 완료되면 Paddle 벤더 계정을 신청할 수 있습니다. 프로덕션 배포 전에 Paddle로부터 애플리케이션 도메인에 대한 승인을 받아야 한다는 점을 유의하세요.

Cashier (Paddle)

설정

Billable 모델

Cashier를 사용하기 전에, 먼저 사용자 모델에 Billable 트레이트를 추가해야 합니다. 이 트레이트는 구독 생성, 결제 수단 정보 업데이트 등 일반적인 결제 관련 작업을 위한 다양한 메서드를 제공합니다:

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

사용자 외에 다른 엔티티(예: 팀, 조직)에도 결제 기능이 필요하다면, 해당 클래스에도 동일하게 트레이트를 추가할 수 있습니다:

use Illuminate\Database\Eloquent\Model; use Laravel\Paddle\Billable; class Team extends Model { use Billable; }

API 키

다음으로, Paddle API 키를 애플리케이션의 .env 파일에 설정해야 합니다. API 키는 Paddle 관리 패널에서 확인할 수 있습니다:

PADDLE_CLIENT_SIDE_TOKEN=your-paddle-client-side-token PADDLE_API_KEY=your-paddle-api-key PADDLE_RETAIN_KEY=your-paddle-retain-key PADDLE_WEBHOOK_SECRET="your-paddle-webhook-secret" PADDLE_SANDBOX=true

Paddle 샌드박스 환경을 사용할 때는 PADDLE_SANDBOXtrue로 설정하세요. 실제 운영 환경에 배포할 때는 false로 변경해야 합니다.

PADDLE_RETAIN_KEY는 선택 사항으로, Paddle의 Retain 기능을 사용할 때만 설정하면 됩니다.

Paddle JS

Paddle은 자체 JavaScript 라이브러리를 통해 결제 위젯을 초기화합니다. 레이아웃 파일의 </head> 태그 바로 앞에 @paddleJS Blade 디렉티브를 추가하여 라이브러리를 로드하세요:

<head> ... @paddleJS </head>

통화 설정

청구서에 금액을 표시할 때 사용할 로케일을 지정할 수 있습니다. Cashier는 내부적으로 PHP의 NumberFormatter 클래스를 사용하여 통화 형식을 지정합니다. 예를 들어 한국 원화로 표시하려면 다음과 같이 설정합니다:

CASHIER_CURRENCY_LOCALE=ko_KR

WARNING

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

기본 모델 재정의

Cashier가 내부적으로 사용하는 모델을 직접 확장하여 커스터마이징할 수 있습니다. Cashier 모델을 상속받아 새로운 모델을 정의하면 됩니다:

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

모델을 정의한 후에는 App\Providers\AppServiceProviderboot 메서드에서 Cashier가 커스텀 모델을 사용하도록 지정하세요:

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

Cashier (Paddle)

빠른 시작

단일 상품 판매

NOTE

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

애플리케이션에 결제 기능을 직접 구현하는 일은 복잡하게 느껴질 수 있습니다. 하지만 Cashier와 Paddle Checkout Overlay를 활용하면, 현대적이고 안정적인 결제 통합을 손쉽게 구현할 수 있습니다.

반복 결제가 아닌 단건 상품을 판매할 때는 Cashier의 checkout 메서드를 사용해 Paddle Checkout Overlay를 띄웁니다. 고객은 해당 오버레이에서 결제 정보를 입력하고 구매를 확정합니다. 결제가 완료되면 고객은 애플리케이션 내 지정한 성공 페이지로 리다이렉트됩니다.

use Illuminate\Http\Request; Route::get('/buy', function (Request $request) { $checkout = $request->user()->checkout('pri_deluxe_album') ->returnTo(route('dashboard')); return view('buy', ['checkout' => $checkout]); })->name('checkout');

위 예시에서 checkout 메서드는 특정 "가격 식별자(price identifier)"에 대한 체크아웃 객체를 생성합니다. Paddle에서 "가격(price)"은 특정 상품에 정의된 가격을 의미합니다.

필요한 경우 checkout 메서드는 Paddle에 고객을 자동으로 생성하고, 애플리케이션 데이터베이스의 사용자 레코드와 연결합니다. 체크아웃 세션이 완료되면 고객은 별도의 성공 페이지로 리다이렉트되어 안내 메시지를 확인할 수 있습니다.

buy 뷰에는 Checkout Overlay를 표시하는 버튼을 포함합니다. Cashier Paddle에는 paddle-button Blade 컴포넌트가 기본 제공됩니다. 직접 렌더링이 필요한 경우에는 오버레이 체크아웃 수동 렌더링을 참고하세요.

<x-paddle-button :checkout="$checkout" class="px-8 py-4"> 상품 구매 </x-paddle-button>

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

상품을 판매할 때는 보통 Cart(장바구니)나 Order(주문) 모델을 통해 완료된 주문과 구매 상품을 추적합니다. 고객을 Paddle Checkout Overlay로 보낼 때 기존 주문 식별자를 함께 전달하면, 결제 완료 후 고객이 돌아왔을 때 해당 주문과 연결할 수 있습니다.

이를 위해 checkout 메서드에 커스텀 데이터 배열을 전달할 수 있습니다. 아래 예시에서는 사용자가 체크아웃을 시작할 때 애플리케이션 내에 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', ]); $checkout = $request->user()->checkout($order->price_ids) ->customData(['order_id' => $order->id]); return view('billing', ['checkout' => $checkout]); })->name('checkout');

위 예시처럼, 체크아웃 시작 시 장바구니 또는 주문에 연결된 Paddle 가격 식별자를 checkout 메서드에 전달합니다. 고객이 상품을 담을 때 이 항목들을 장바구니나 주문과 연결하는 것은 애플리케이션의 책임입니다. 또한 customData 메서드를 통해 주문 ID를 Paddle Checkout Overlay에 전달합니다.

고객이 체크아웃을 완료하면 해당 주문을 "완료" 상태로 변경해야 합니다. 이를 위해 Paddle이 전송하는 웹훅을 Cashier가 이벤트로 발행하는 것을 리스닝하면 됩니다.

먼저 Cashier가 발행하는 TransactionCompleted 이벤트를 리스닝합니다. 일반적으로 AppServiceProviderboot 메서드에 이벤트 리스너를 등록합니다.

use App\Listeners\CompleteOrder; use Illuminate\Support\Facades\Event; use Laravel\Paddle\Events\TransactionCompleted; /** * 애플리케이션 서비스를 부트스트랩합니다. */ public function boot(): void { Event::listen(TransactionCompleted::class, CompleteOrder::class); }

이 예시에서 CompleteOrder 리스너는 다음과 같이 작성할 수 있습니다.

namespace App\Listeners; use App\Models\Order; use Laravel\Paddle\Cashier; use Laravel\Paddle\Events\TransactionCompleted; class CompleteOrder { /** * Cashier 웹훅 이벤트를 처리합니다. */ public function handle(TransactionCompleted $event): void { $orderId = $event->payload['data']['custom_data']['order_id'] ?? null; $order = Order::findOrFail($orderId); $order->update(['status' => 'completed']); } }

transaction.completed 이벤트에 포함된 데이터에 대한 자세한 내용은 Paddle 공식 문서를 참고하세요.

구독 서비스 판매

NOTE

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

Cashier와 Paddle Checkout Overlay를 함께 사용하면 구독 결제도 손쉽게 구현할 수 있습니다.

구독 판매 방법을 살펴보기 위해 간단한 시나리오를 가정해 보겠습니다. 월간 플랜(price_basic_monthly)과 연간 플랜(price_basic_yearly)으로 구성된 구독 서비스가 있다고 합시다. 두 플랜은 Paddle 대시보드에서 "Basic" 상품(pro_basic)으로 묶여 있으며, "Expert" 플랜(pro_expert)도 함께 제공한다고 가정합니다.

먼저 고객이 서비스에 구독하는 흐름을 살펴보겠습니다. 고객이 가격 안내 페이지에서 Basic 플랜의 "구독하기" 버튼을 클릭하면 Paddle Checkout Overlay가 표시됩니다. checkout 메서드로 체크아웃 세션을 시작하는 라우트를 아래와 같이 정의합니다.

use Illuminate\Http\Request; Route::get('/subscribe', function (Request $request) { $checkout = $request->user()->checkout('price_basic_monthly') ->returnTo(route('dashboard')); return view('subscribe', ['checkout' => $checkout]); })->name('subscribe');

subscribe 뷰에는 Checkout Overlay를 표시하는 버튼을 포함합니다. paddle-button Blade 컴포넌트를 사용하거나, 필요하다면 오버레이 체크아웃 수동 렌더링을 참고하세요.

<x-paddle-button :checkout="$checkout" class="px-8 py-4"> 구독하기 </x-paddle-button>

이제 고객이 구독하기 버튼을 클릭하면 결제 정보를 입력하고 구독을 시작할 수 있습니다. 일부 결제 수단은 처리에 수 초가 소요될 수 있으므로, 구독이 실제로 시작된 시점을 정확히 파악하려면 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('/subscribe'); } return $next($request); } }

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

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

고객이 구독 플랜을 직접 관리하도록 허용하기

고객은 구독 플랜을 다른 상품이나 등급으로 변경하고 싶을 수 있습니다. 앞선 예시에서라면 월간 구독을 연간 구독으로 전환하는 기능이 이에 해당합니다. 아래 라우트로 연결되는 버튼을 구현하면 됩니다.

use Illuminate\Http\Request; Route::put('/subscription/{price}/swap', function (Request $request, $price) { $user->subscription()->swap($price); // 이 예시에서 $price는 "price_basic_yearly" return redirect()->route('dashboard'); })->name('subscription.swap');

플랜 변경 외에도 구독 해지 기능도 제공해야 합니다. 마찬가지로 아래 라우트로 연결되는 버튼을 구현합니다.

use Illuminate\Http\Request; Route::put('/subscription/cancel', function (Request $request) { $request->user()->subscription()->cancel(); return redirect()->route('dashboard'); })->name('subscription.cancel');

구독을 해지하면 현재 결제 주기가 끝날 때 구독이 만료됩니다.

NOTE

Cashier 웹훅 처리를 설정해 두면, Paddle에서 전송하는 웹훅을 수신해 애플리케이션의 Cashier 관련 데이터베이스 테이블을 자동으로 동기화합니다. 예를 들어 Paddle 대시보드에서 고객의 구독을 해지하면, Cashier가 해당 웹훅을 수신하여 데이터베이스의 구독 상태를 "canceled"로 자동 변경합니다.

Cashier (Paddle)

결제 세션 (Checkout Sessions)

고객에게 청구하는 대부분의 작업은 Paddle의 Checkout Overlay 위젯이나 인라인 결제(Inline Checkout)를 통해 이루어집니다.

Paddle로 결제를 처리하기 전에, Paddle 결제 설정 대시보드에서 애플리케이션의 기본 결제 링크(default payment link)를 먼저 등록해야 합니다.

오버레이 결제 (Overlay Checkout)

Checkout Overlay 위젯을 표시하려면 먼저 Cashier를 통해 결제 세션을 생성해야 합니다. 결제 세션은 위젯에 어떤 청구 작업을 수행할지 알려주는 역할을 합니다.

use Illuminate\Http\Request; Route::get('/buy', function (Request $request) { $checkout = $user->checkout('pri_34567') ->returnTo(route('dashboard')); return view('billing', ['checkout' => $checkout]); });

Cashier에는 paddle-button이라는 Blade 컴포넌트가 포함되어 있습니다. 생성한 결제 세션을 이 컴포넌트에 prop으로 전달하면, 버튼을 클릭했을 때 Paddle 결제 위젯이 화면에 표시됩니다.

<x-paddle-button :checkout="$checkout" class="px-8 py-4"> 구독하기 </x-paddle-button>

기본적으로 Paddle의 기본 스타일로 위젯이 표시됩니다. data-theme='light'와 같은 Paddle 지원 속성을 컴포넌트에 추가하면 위젯 스타일을 커스터마이징할 수 있습니다.

<x-paddle-button :checkout="$checkout" class="px-8 py-4" data-theme="light"> 구독하기 </x-paddle-button>

Paddle 결제 위젯은 비동기로 동작합니다. 사용자가 위젯 내에서 구독을 완료하면 Paddle이 애플리케이션으로 웹훅을 전송하고, 이를 통해 데이터베이스의 구독 상태를 업데이트할 수 있습니다. 따라서 Paddle의 상태 변경에 올바르게 대응하려면 웹훅 설정을 반드시 해두어야 합니다.

WARNING

구독 상태가 변경된 후 웹훅이 도착하는 데 걸리는 시간은 일반적으로 짧지만, 결제 완료 직후에는 구독 정보가 아직 반영되지 않았을 수 있습니다. 이 점을 고려하여 애플리케이션을 설계하세요.

오버레이 결제 직접 렌더링하기

Laravel의 내장 Blade 컴포넌트를 사용하지 않고 오버레이 결제를 직접 렌더링할 수도 있습니다. 먼저 앞선 예시와 동일하게 결제 세션을 생성합니다.

use Illuminate\Http\Request; Route::get('/buy', function (Request $request) { $checkout = $user->checkout('pri_34567') ->returnTo(route('dashboard')); return view('billing', ['checkout' => $checkout]); });

다음으로 Paddle.js를 사용해 결제를 초기화합니다. 아래 예시에서는 paddle_button 클래스가 지정된 링크를 생성합니다. Paddle.js가 이 클래스를 감지하고 링크를 클릭하면 오버레이 결제 화면을 표시합니다.

<?php $items = $checkout->getItems(); $customer = $checkout->getCustomer(); $custom = $checkout->getCustomData(); ?> <a href='#!' class='paddle_button' data-items='{!! json_encode($items) !!}' @if ($customer) data-customer-id='{{ $customer->paddle_id }}' @endif @if ($custom) data-custom-data='{{ json_encode($custom) }}' @endif @if ($returnUrl = $checkout->getReturnUrl()) data-success-url='{{ $returnUrl }}' @endif > 상품 구매하기 </a>

인라인 결제 (Inline Checkout)

Paddle의 오버레이 방식 대신, 결제 위젯을 페이지 안에 직접 삽입하는 인라인 결제 방식도 제공됩니다. 이 방식은 결제 HTML 필드를 직접 수정할 수는 없지만, 위젯을 애플리케이션 레이아웃 안에 자연스럽게 녹여낼 수 있습니다.

Cashier에는 인라인 결제를 손쉽게 사용할 수 있도록 paddle-checkout Blade 컴포넌트가 포함되어 있습니다. 먼저 결제 세션을 생성합니다.

use Illuminate\Http\Request; Route::get('/buy', function (Request $request) { $checkout = $user->checkout('pri_34567') ->returnTo(route('dashboard')); return view('billing', ['checkout' => $checkout]); });

그런 다음 생성한 결제 세션을 컴포넌트의 checkout 속성에 전달합니다.

<x-paddle-checkout :checkout="$checkout" class="w-full" />

인라인 결제 컴포넌트의 높이를 조정하려면 height 속성을 사용하세요.

<x-paddle-checkout :checkout="$checkout" class="w-full" height="500" />

인라인 결제의 추가 커스터마이징 옵션은 Paddle의 인라인 결제 가이드결제 설정 옵션 문서를 참고하세요.

인라인 결제 직접 렌더링하기

내장 Blade 컴포넌트 없이 인라인 결제를 직접 렌더링할 수도 있습니다. 먼저 앞선 예시와 동일하게 결제 세션을 생성합니다.

use Illuminate\Http\Request; Route::get('/buy', function (Request $request) { $checkout = $user->checkout('pri_34567') ->returnTo(route('dashboard')); return view('billing', ['checkout' => $checkout]); });

다음으로 Paddle.js를 사용해 결제를 초기화합니다. 아래 예시는 Alpine.js를 사용하지만, 프로젝트의 프론트엔드 스택에 맞게 자유롭게 수정할 수 있습니다.

<?php $options = $checkout->options(); $options['settings']['frameTarget'] = 'paddle-checkout'; $options['settings']['frameInitialHeight'] = 366; ?> <div class="paddle-checkout" x-data="{}" x-init=" Paddle.Checkout.open(@json($options)); "> </div>

비회원 결제 (Guest Checkouts)

애플리케이션 계정 없이 결제해야 하는 비회원 사용자를 위한 결제 세션도 생성할 수 있습니다. guest 메서드를 사용하면 됩니다.

use Illuminate\Http\Request; use Laravel\Paddle\Checkout; Route::get('/buy', function (Request $request) { $checkout = Checkout::guest(['pri_34567']) ->returnTo(route('home')); return view('billing', ['checkout' => $checkout]); });

생성한 결제 세션은 Paddle 버튼 또는 인라인 결제 Blade 컴포넌트에 동일하게 전달할 수 있습니다.

Cashier (Paddle)

가격 미리보기

Paddle은 통화별로 가격을 커스터마이즈할 수 있어, 국가마다 다른 가격을 설정할 수 있습니다. Cashier Paddle은 previewPrices 메서드를 통해 이러한 가격 정보를 모두 조회할 수 있습니다. 이 메서드에는 조회하려는 가격 ID 배열을 전달합니다:

use Laravel\Paddle\Cashier; $prices = Cashier::previewPrices(['pri_123', 'pri_456']);

통화는 요청의 IP 주소를 기반으로 자동 결정됩니다. 특정 국가의 가격을 조회하고 싶다면 address 옵션으로 국가 코드와 우편번호를 직접 지정할 수도 있습니다:

use Laravel\Paddle\Cashier; $prices = Cashier::previewPrices(['pri_123', 'pri_456'], ['address' => [ 'country_code' => 'KR', 'postal_code' => '06141', ]]);

조회한 가격은 원하는 방식으로 자유롭게 표시할 수 있습니다:

<ul> @foreach ($prices as $price) <li>{{ $price->product['name'] }} - {{ $price->total() }}</li> @endforeach </ul>

소계와 세금을 분리해서 표시할 수도 있습니다:

<ul> @foreach ($prices as $price) <li>{{ $price->product['name'] }} - {{ $price->subtotal() }} (+ {{ $price->tax() }} 세금)</li> @endforeach </ul>

자세한 내용은 가격 미리보기에 관한 Paddle API 문서를 참고하세요.

특정 고객에 대한 가격 미리보기

이미 등록된 고객에게 적용되는 가격을 보여주고 싶다면, 고객 인스턴스에서 직접 가격을 조회하면 됩니다:

use App\Models\User; $prices = User::find(1)->previewPrices(['pri_123', 'pri_456']);

내부적으로 Cashier는 해당 사용자의 고객 ID를 활용해 사용자 통화에 맞는 가격을 가져옵니다. 예를 들어, 미국에 거주하는 사용자에게는 미국 달러(USD)로, 한국에 거주하는 사용자에게는 원화(KRW)로 가격이 표시됩니다. 일치하는 통화가 없을 경우에는 해당 상품의 기본 통화가 사용됩니다. 상품 또는 구독 플랜의 가격은 Paddle 관리 패널에서 커스터마이즈할 수 있습니다.

할인 적용 가격 미리보기

할인이 적용된 가격을 미리 보여주고 싶다면, previewPrices 메서드 호출 시 discount_id 옵션으로 할인 ID를 전달하면 됩니다:

use Laravel\Paddle\Cashier; $prices = Cashier::previewPrices(['pri_123', 'pri_456'], [ 'discount_id' => 'dsc_123' ]);

이후 계산된 가격을 다음과 같이 표시할 수 있습니다:

<ul> @foreach ($prices as $price) <li>{{ $price->product['name'] }} - {{ $price->total() }}</li> @endforeach </ul>

Cashier (Paddle)

고객 (Customers)

고객 기본값 설정

Cashier를 사용하면 체크아웃 세션을 생성할 때 고객 정보에 대한 기본값을 미리 지정할 수 있습니다. 기본값을 설정해두면 결제 위젯에서 고객의 이름과 이메일이 자동으로 채워지므로, 고객이 곧바로 결제 단계로 넘어갈 수 있습니다.

빌링 모델에서 아래 메서드를 오버라이드하여 기본값을 설정하세요:

/** * Paddle에 연결할 고객 이름을 반환합니다. */ public function paddleName(): string|null { return $this->name; } /** * Paddle에 연결할 고객 이메일 주소를 반환합니다. */ public function paddleEmail(): string|null { return $this->email; }

이 기본값은 Cashier에서 체크아웃 세션을 생성하는 모든 동작에 자동으로 적용됩니다.

고객 조회

Paddle 고객 ID를 사용해 고객을 조회하려면 Cashier::findBillable 메서드를 사용합니다. 이 메서드는 빌링 모델 인스턴스를 반환합니다:

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

고객 생성

구독을 바로 시작하지 않고 Paddle 고객만 먼저 생성하고 싶을 때는 createAsCustomer 메서드를 사용합니다:

$customer = $user->createAsCustomer();

이 메서드는 Laravel\Paddle\Customer 인스턴스를 반환합니다. Paddle에 고객이 생성된 이후에는 언제든지 구독을 시작할 수 있습니다. Paddle API가 지원하는 고객 생성 파라미터를 추가로 전달하려면 $options 배열을 인자로 넘기면 됩니다:

$customer = $user->createAsCustomer($options);

구독 (Subscriptions)

구독 생성

구독을 생성하려면 먼저 데이터베이스에서 청구 가능한 모델 인스턴스(일반적으로 App\Models\User)를 가져옵니다. 모델 인스턴스를 가져온 후 subscribe 메서드를 사용해 체크아웃 세션을 생성할 수 있습니다.

use Illuminate\Http\Request; Route::get('/user/subscribe', function (Request $request) { $checkout = $request->user()->subscribe($premium = 'pri_123', 'default') ->returnTo(route('home')); return view('billing', ['checkout' => $checkout]); });

subscribe 메서드의 첫 번째 인수는 사용자가 구독할 가격의 식별자입니다. 이 값은 Paddle에 등록된 가격 ID와 일치해야 합니다. returnTo 메서드에는 체크아웃 완료 후 사용자를 리디렉션할 URL을 전달합니다.

두 번째 인수는 구독의 내부 "타입"입니다. 애플리케이션에 구독 종류가 하나뿐이라면 default 또는 primary로 지정하면 됩니다. 이 타입은 애플리케이션 내부에서만 사용되며 사용자에게 표시되지 않습니다. 또한 공백을 포함할 수 없고, 구독 생성 후에는 절대 변경해서는 안 됩니다.

customData 메서드를 사용하면 구독에 커스텀 메타데이터를 함께 전달할 수 있습니다.

$checkout = $request->user()->subscribe($premium = 'pri_123', 'default') ->customData(['key' => 'value']) ->returnTo(route('home'));

체크아웃 세션이 생성되면 Cashier Paddle에 포함된 paddle-button Blade 컴포넌트에 전달해 결제 버튼을 렌더링합니다.

<x-paddle-button :checkout="$checkout" class="px-8 py-4"> 구독하기 </x-paddle-button>

사용자가 체크아웃을 완료하면 Paddle이 subscription_created 웹훅을 발송합니다. Cashier는 이 웹훅을 수신해 고객의 구독을 설정합니다. 웹훅이 정상적으로 수신·처리되도록 웹훅 처리 설정을 반드시 완료하세요.

구독 상태 확인

사용자가 구독 중인지 확인할 때 사용할 수 있는 다양한 메서드가 있습니다. 먼저 subscribed 메서드는 사용자가 유효한 구독(트라이얼 기간 포함)을 보유하고 있으면 true를 반환합니다.

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

애플리케이션이 여러 구독 타입을 제공한다면, 특정 구독 타입을 지정해서 확인할 수 있습니다.

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()) { // 유료 구독자가 아닙니다... return redirect('/billing'); } return $next($request); } }

사용자가 아직 트라이얼 기간 중인지 확인하려면 onTrial 메서드를 사용하세요. 트라이얼 중임을 사용자에게 안내 문구로 표시할 때 유용합니다.

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

subscribedToPrice 메서드는 특정 Paddle 가격 ID를 기준으로 사용자가 해당 플랜에 활성 구독 중인지 확인합니다. 아래 예시에서는 default 구독이 월간 가격에 활성화되어 있는지 확인합니다.

if ($user->subscribedToPrice($monthly = 'pri_123', 'default')) { // ... }

recurring 메서드는 사용자가 활성 구독 중이며 트라이얼 기간이나 유예 기간도 아닌 상태인지 확인합니다.

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

구독 취소 상태

사용자가 이전에 구독했다가 취소했는지 확인하려면 canceled 메서드를 사용하세요.

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

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

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

결제 연체 상태

구독 결제가 실패하면 구독 상태가 past_due로 표시됩니다. 이 상태에서는 고객이 결제 수단을 업데이트하기 전까지 구독이 활성화되지 않습니다. pastDue 메서드로 이 상태를 확인할 수 있습니다.

if ($user->subscription()->pastDue()) { // ... }

구독이 연체 상태일 때는 사용자에게 결제 수단 업데이트를 안내해야 합니다.

past_due 상태의 구독도 유효한 것으로 간주하려면 Cashier가 제공하는 keepPastDueSubscriptionsActive 메서드를 사용하세요. 이 메서드는 일반적으로 AppServiceProviderregister 메서드에서 호출합니다.

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

WARNING

구독이 past_due 상태일 때는 결제 수단이 업데이트될 때까지 변경이 불가능합니다. 따라서 이 상태에서 swap 또는 updateQuantity 메서드를 호출하면 예외가 발생합니다.

구독 쿼리 스코프

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

// 유효한 구독 전체 조회... $subscriptions = Subscription::query()->valid()->get(); // 특정 사용자의 취소된 구독 조회... $subscriptions = $user->subscriptions()->canceled()->get();

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

Subscription::query()->valid(); Subscription::query()->onTrial(); Subscription::query()->expiredTrial(); Subscription::query()->notOnTrial(); Subscription::query()->active(); Subscription::query()->recurring(); Subscription::query()->pastDue(); Subscription::query()->paused(); Subscription::query()->notPaused(); Subscription::query()->onPausedGracePeriod(); Subscription::query()->notOnPausedGracePeriod(); Subscription::query()->canceled(); Subscription::query()->notCanceled(); Subscription::query()->onGracePeriod(); Subscription::query()->notOnGracePeriod();

구독 단건 청구

구독 단건 청구를 사용하면 기존 구독에 일회성 추가 요금을 부과할 수 있습니다. charge 메서드 호출 시 하나 또는 여러 개의 가격 ID를 전달합니다.

// 단일 가격 청구... $response = $user->subscription()->charge('pri_123'); // 여러 가격 동시 청구... $response = $user->subscription()->charge(['pri_123', 'pri_456']);

charge 메서드는 다음 청구 주기에 실제 청구가 이루어집니다. 즉시 청구하려면 chargeAndInvoice 메서드를 사용하세요.

$response = $user->subscription()->chargeAndInvoice('pri_123');

결제 수단 업데이트

Paddle은 구독별로 결제 수단을 저장합니다. 구독의 기본 결제 수단을 변경하려면, 구독 모델의 redirectToUpdatePaymentMethod 메서드를 사용해 Paddle이 호스팅하는 결제 수단 업데이트 페이지로 고객을 리디렉션하세요.

use Illuminate\Http\Request; Route::get('/update-payment-method', function (Request $request) { $user = $request->user(); return $user->subscription()->redirectToUpdatePaymentMethod(); });

사용자가 결제 수단 업데이트를 완료하면 Paddle이 subscription_updated 웹훅을 발송하고, 애플리케이션 데이터베이스의 구독 정보가 자동으로 업데이트됩니다.

플랜 변경

사용자가 구독 후 다른 플랜으로 변경하려는 경우, 구독의 swap 메서드에 Paddle 가격 ID를 전달하면 됩니다.

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

다음 청구 주기를 기다리지 않고 즉시 플랜을 변경하고 청구서를 발행하려면 swapAndInvoice 메서드를 사용하세요.

$user = User::find(1); $user->subscription()->swapAndInvoice($premium = 'pri_456');

일할 계산 (Proration)

기본적으로 Paddle은 플랜 변경 시 요금을 일할 계산합니다. 일할 계산 없이 구독을 변경하려면 noProrate 메서드를 사용하세요.

$user->subscription('default')->noProrate()->swap($premium = 'pri_456');

일할 계산을 적용하지 않고 즉시 청구서를 발행하려면 noProrateswapAndInvoice를 함께 사용합니다.

$user->subscription('default')->noProrate()->swapAndInvoice($premium = 'pri_456');

플랜 변경에 대해 고객에게 아무런 요금도 청구하지 않으려면 doNotBill 메서드를 사용하세요.

$user->subscription('default')->doNotBill()->swap($premium = 'pri_456');

Paddle의 일할 계산 정책에 대한 자세한 내용은 Paddle 일할 계산 문서를 참고하세요.

구독 수량

구독 수량이 적용되는 경우도 있습니다. 예를 들어 프로젝트 관리 서비스에서 프로젝트 하나당 월 10,000원을 청구하는 구조가 이에 해당합니다. incrementQuantitydecrementQuantity 메서드로 수량을 손쉽게 조정할 수 있습니다.

$user = User::find(1); $user->subscription()->incrementQuantity(); // 현재 수량에 5를 추가... $user->subscription()->incrementQuantity(5); $user->subscription()->decrementQuantity(); // 현재 수량에서 5를 차감... $user->subscription()->decrementQuantity(5);

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

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

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

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

다중 상품 구독의 수량 관리

다중 상품 구독의 경우, 수량을 조정할 가격 ID를 두 번째 인수로 전달합니다.

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

다중 상품 구독

다중 상품 구독을 사용하면 하나의 구독에 여러 결제 상품을 연결할 수 있습니다. 예를 들어 월 10,000원의 기본 구독에 라이브 채팅 기능을 월 15,000원의 추가 상품으로 제공하는 고객센터 서비스를 구성할 수 있습니다.

구독 체크아웃 세션을 생성할 때 subscribe 메서드의 첫 번째 인수로 가격 ID 배열을 전달하면 됩니다.

use Illuminate\Http\Request; Route::post('/user/subscribe', function (Request $request) { $checkout = $request->user()->subscribe([ 'price_monthly', 'price_chat', ]); return view('billing', ['checkout' => $checkout]); });

위 예시에서 고객의 default 구독에는 두 개의 가격이 연결되며, 각각 해당 청구 주기에 맞게 청구됩니다. 필요하다면 연관 배열로 각 가격의 수량을 지정할 수도 있습니다.

$user = User::find(1); $checkout = $user->subscribe('default', ['price_monthly', 'price_chat' => 5]);

기존 구독에 새 가격을 추가하려면 swap 메서드를 사용합니다. 이때 기존 가격과 수량도 함께 포함해야 합니다.

$user = User::find(1); $user->subscription()->swap(['price_chat', 'price_original' => 2]);

위 예시는 새 가격을 추가하지만 다음 청구 주기까지 실제 청구는 이루어지지 않습니다. 즉시 청구하려면 swapAndInvoice를 사용하세요.

$user->subscription()->swapAndInvoice(['price_chat', 'price_original' => 2]);

특정 가격을 제거하려면 swap 메서드 호출 시 해당 가격을 배열에서 제외하면 됩니다.

$user->subscription()->swap(['price_original' => 2]);

WARNING

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

다중 구독

Paddle은 고객이 여러 구독을 동시에 보유할 수 있도록 지원합니다. 예를 들어 헬스장에서 수영 구독과 헬스 구독을 각각 다른 가격으로 제공하고, 고객이 두 가지 모두 또는 원하는 것만 구독할 수 있는 구조가 이에 해당합니다.

구독을 생성할 때 subscribe 메서드의 두 번째 인수로 구독 타입을 지정하면 됩니다.

use Illuminate\Http\Request; Route::post('/swimming/subscribe', function (Request $request) { $checkout = $request->user()->subscribe($swimmingMonthly = 'pri_123', 'swimming'); return view('billing', ['checkout' => $checkout]); });

이 예시에서는 고객에게 월간 수영 구독을 생성합니다. 나중에 연간 구독으로 변경하려면 swimming 구독의 가격을 교체하면 됩니다.

$user->subscription('swimming')->swap($swimmingYearly = 'pri_456');

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

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

구독 일시정지

구독을 일시정지하려면 pause 메서드를 호출하세요.

$user->subscription()->pause();

구독이 일시정지되면 Cashier는 데이터베이스의 paused_at 컬럼을 자동으로 설정합니다. 예를 들어 고객이 3월 1일에 구독을 일시정지했지만 다음 청구일이 3월 5일이라면, paused 메서드는 3월 5일까지 false를 반환합니다. 사용자가 이미 결제한 기간은 계속 이용할 수 있기 때문입니다.

기본적으로 일시정지는 다음 청구 주기에 적용됩니다. 즉시 일시정지하려면 pauseNow 메서드를 사용하세요.

$user->subscription()->pauseNow();

pauseUntil 메서드를 사용하면 특정 시점까지만 일시정지할 수 있습니다.

$user->subscription()->pauseUntil(now()->plus(months: 1));

즉시 일시정지하면서 특정 시점에 자동으로 재개되도록 하려면 pauseNowUntil 메서드를 사용하세요.

$user->subscription()->pauseNowUntil(now()->plus(months: 1));

일시정지했지만 아직 "유예 기간" 중인지 확인하려면 onPausedGracePeriod 메서드를 사용합니다.

if ($user->subscription()->onPausedGracePeriod()) { // ... }

일시정지된 구독을 재개하려면 resume 메서드를 호출하세요.

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

WARNING

구독이 일시정지된 상태에서는 수정이 불가능합니다. 플랜을 변경하거나 수량을 업데이트하려면 먼저 구독을 재개해야 합니다.

구독 취소

구독을 취소하려면 cancel 메서드를 호출하세요.

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

구독이 취소되면 Cashier는 데이터베이스의 ends_at 컬럼을 자동으로 설정합니다. 예를 들어 3월 1일에 구독을 취소했지만 원래 만료일이 3월 5일이라면, subscribed 메서드는 3월 5일까지 true를 반환합니다. 사용자가 청구 주기 말일까지는 서비스를 계속 이용할 수 있도록 하기 위함입니다.

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

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

즉시 구독을 취소하려면 cancelNow 메서드를 호출하세요.

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

유예 기간 중인 구독의 취소를 철회하려면 stopCancelation 메서드를 호출합니다.

$user->subscription()->stopCancelation();

WARNING

Paddle 구독은 취소 후 재개할 수 없습니다. 고객이 다시 구독을 원한다면 새로운 구독을 생성해야 합니다.

Cashier (Paddle)

구독 체험 기간 (Trial)

결제 수단을 먼저 수집하는 체험 기간

체험 기간을 제공하면서도 결제 수단 정보를 미리 수집하고 싶다면, Paddle 대시보드에서 해당 가격(price)에 체험 기간을 설정하세요. 설정 후에는 평소와 동일하게 체크아웃 세션을 시작하면 됩니다.

use Illuminate\Http\Request; Route::get('/user/subscribe', function (Request $request) { $checkout = $request->user() ->subscribe('pri_monthly') ->returnTo(route('home')); return view('billing', ['checkout' => $checkout]); });

애플리케이션이 subscription_created 이벤트를 수신하면, Cashier는 데이터베이스의 구독 레코드에 체험 종료일을 저장하고, 해당 날짜 이전에는 Paddle이 고객에게 청구를 시작하지 않도록 처리합니다.

WARNING

체험 기간이 끝나기 전에 구독을 취소하지 않으면, 체험 종료 즉시 결제가 발생합니다. 반드시 사용자에게 체험 종료일을 미리 안내하세요.

사용자가 현재 체험 기간 중인지 확인하려면 onTrial 메서드를 사용합니다.

if ($user->onTrial()) { // 체험 기간 중... }

체험 기간이 이미 만료되었는지 확인하려면 hasExpiredTrial 메서드를 사용합니다.

if ($user->hasExpiredTrial()) { // 체험 기간 만료됨... }

특정 구독 타입에 대해 체험 여부를 확인하려면, 타입명을 인수로 전달합니다.

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

결제 수단 없이 제공하는 체험 기간

결제 수단 정보를 미리 받지 않고 체험 기간을 제공하고 싶다면, 사용자에게 연결된 고객 레코드의 trial_ends_at 컬럼에 원하는 체험 종료일을 직접 설정하면 됩니다. 보통은 회원 가입 시점에 처리합니다.

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

Cashier는 이 방식을 "일반 체험(generic trial)" 이라고 부릅니다. 실제 구독과 연결되지 않은 체험이기 때문입니다. trial_ends_at 값이 현재 날짜를 지나지 않았다면, onTrial 메서드는 true를 반환합니다.

if ($user->onTrial()) { // 체험 기간 중... }

실제 구독을 생성할 준비가 되면, 평소처럼 subscribe 메서드를 사용합니다.

use Illuminate\Http\Request; Route::get('/user/subscribe', function (Request $request) { $checkout = $request->user() ->subscribe('pri_monthly') ->returnTo(route('home')); return view('billing', ['checkout' => $checkout]); });

사용자의 체험 종료일을 가져오려면 trialEndsAt 메서드를 사용합니다. 체험 중이라면 Carbon 인스턴스를 반환하고, 체험 중이 아니라면 null을 반환합니다. 기본 구독이 아닌 특정 구독 타입의 체험 종료일을 조회할 때는 타입명을 인수로 전달할 수 있습니다.

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

사용자가 아직 실제 구독을 생성하지 않은 "일반 체험" 상태인지 정확히 확인하고 싶다면 onGenericTrial 메서드를 사용합니다.

if ($user->onGenericTrial()) { // "일반 체험" 기간 중 (실제 구독 없음)... }

NOTE

onTrial()은 일반 체험과 구독 연동 체험 모두에서 true를 반환합니다. 반면 onGenericTrial()은 실제 구독 없이 trial_ends_at만 설정된 경우에만 true를 반환합니다. 두 방식을 구분해야 할 때는 onGenericTrial()을 활용하세요.

체험 기간 연장 또는 즉시 활성화

기존 구독의 체험 기간을 연장하려면 extendTrial 메서드에 새로운 종료 시점을 지정합니다.

$user->subscription()->extendTrial(now()->plus(days: 5));

반대로 체험 기간을 즉시 종료하고 구독을 활성화하려면 activate 메서드를 호출합니다.

$user->subscription()->activate();

Cashier (Paddle)

Paddle 웹훅 처리

Paddle은 다양한 이벤트가 발생했을 때 웹훅(webhook)을 통해 여러분의 애플리케이션에 알림을 전송합니다. Cashier 서비스 프로바이더는 기본적으로 웹훅 컨트롤러를 가리키는 라우트를 자동으로 등록합니다. 이 컨트롤러가 들어오는 모든 웹훅 요청을 처리합니다.

기본적으로 이 컨트롤러는 결제 실패가 반복된 구독 취소, 구독 업데이트, 결제 수단 변경을 자동으로 처리합니다. 아래에서 살펴보겠지만, 이 컨트롤러를 확장하면 원하는 Paddle 웹훅 이벤트를 자유롭게 처리할 수 있습니다.

Paddle 웹훅을 정상적으로 받으려면 Paddle 관리 패널에서 웹훅 URL을 설정해야 합니다. Cashier의 웹훅 컨트롤러는 기본적으로 /paddle/webhook 경로로 응답합니다. Paddle 관리 패널에서 활성화해야 할 웹훅 목록은 다음과 같습니다.

  • Customer Updated
  • Transaction Completed
  • Transaction Updated
  • Subscription Created
  • Subscription Updated
  • Subscription Paused
  • Subscription Canceled

WARNING

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

웹훅과 CSRF 보호

Paddle 웹훅은 Laravel의 CSRF 보호를 우회해야 하므로, Laravel이 Paddle 웹훅 요청에 대해 CSRF 토큰을 검증하지 않도록 설정해야 합니다. bootstrap/app.php 파일에서 paddle/* 경로를 CSRF 보호 대상에서 제외하면 됩니다.

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

웹훅과 로컬 개발 환경

로컬 개발 환경에서 Paddle이 애플리케이션으로 웹훅을 전송하려면, 외부에서 접근할 수 있도록 애플리케이션을 노출해야 합니다. Ngrok이나 Expose 같은 사이트 공유 서비스를 사용하면 됩니다. Laravel Sail로 로컬 개발 중이라면 Sail의 사이트 공유 명령어를 활용할 수 있습니다.

NOTE

국내 환경에서는 방화벽이나 ISP 정책으로 인해 포트 개방이 제한될 수 있습니다. 이런 경우 Ngrok의 유료 플랜이나 터널링 서비스를 이용하는 것이 안정적입니다.

웹훅 이벤트 핸들러 정의

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

  • Laravel\Paddle\Events\WebhookReceived
  • Laravel\Paddle\Events\WebhookHandled

두 이벤트 모두 Paddle 웹훅의 전체 페이로드(payload)를 담고 있습니다. 예를 들어 transaction.billed 웹훅을 처리하고 싶다면, 아래와 같이 해당 이벤트를 처리하는 리스너를 등록합니다.

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

Cashier는 수신한 웹훅 타입에 맞는 전용 이벤트도 발행합니다. 이 이벤트들은 Paddle의 전체 페이로드와 함께, 웹훅 처리에 사용된 청구 대상 모델, 구독, 영수증 등 관련 모델도 포함합니다.

  • Laravel\Paddle\Events\CustomerUpdated
  • Laravel\Paddle\Events\TransactionCompleted
  • Laravel\Paddle\Events\TransactionUpdated
  • Laravel\Paddle\Events\SubscriptionCreated
  • Laravel\Paddle\Events\SubscriptionUpdated
  • Laravel\Paddle\Events\SubscriptionPaused
  • Laravel\Paddle\Events\SubscriptionCanceled

기본 웹훅 라우트를 변경하려면 .env 파일에 CASHIER_WEBHOOK 환경 변수를 정의하면 됩니다. 이 값은 웹훅 라우트의 전체 URL이어야 하며, Paddle 관리 패널에 설정한 URL과 반드시 일치해야 합니다.

CASHIER_WEBHOOK=https://example.com/my-paddle-webhook-url

웹훅 서명 검증

웹훅 보안을 강화하려면 Paddle의 웹훅 서명을 활용하면 됩니다. Cashier는 수신된 Paddle 웹훅 요청의 유효성을 자동으로 검증하는 미들웨어를 기본으로 제공합니다.

웹훅 검증을 활성화하려면 .env 파일에 PADDLE_WEBHOOK_SECRET 환경 변수를 설정해야 합니다. 웹훅 시크릿 값은 Paddle 계정 대시보드에서 확인할 수 있습니다.

Cashier (Paddle)

단건 결제

상품 결제

고객이 상품을 구매하도록 하려면, 청구 가능한 모델 인스턴스의 checkout 메서드를 사용해 결제 세션을 생성할 수 있습니다. checkout 메서드는 하나 또는 여러 개의 가격 ID를 인수로 받습니다. 특정 상품의 수량을 지정하려면 연관 배열 형태로 전달하면 됩니다.

use Illuminate\Http\Request; Route::get('/buy', function (Request $request) { $checkout = $request->user()->checkout(['pri_tshirt', 'pri_socks' => 5]); return view('buy', ['checkout' => $checkout]); });

결제 세션을 생성한 후에는 Cashier가 제공하는 paddle-button Blade 컴포넌트를 사용해 사용자가 Paddle 결제 위젯을 통해 구매를 완료할 수 있도록 합니다.

<x-paddle-button :checkout="$checkout" class="px-8 py-4"> 구매하기 </x-paddle-button>

결제 세션에는 customData 메서드가 있어, 트랜잭션 생성 시 원하는 커스텀 데이터를 함께 전달할 수 있습니다. 커스텀 데이터 전달 시 사용할 수 있는 옵션에 대한 자세한 내용은 Paddle 공식 문서를 참고하세요.

$checkout = $user->checkout('pri_tshirt') ->customData([ 'custom_option' => $value, ]);

트랜잭션 환불

트랜잭션을 환불하면 결제 시 사용한 결제 수단으로 환불 금액이 반환됩니다. Paddle 결제를 환불하려면 Cashier\Paddle\Transaction 모델의 refund 메서드를 사용합니다. 이 메서드는 첫 번째 인수로 환불 사유를 받고, 하나 이상의 가격 ID와 선택적으로 금액을 연관 배열 형태로 전달할 수 있습니다. 특정 청구 가능 모델의 트랜잭션 목록은 transactions 메서드로 조회할 수 있습니다.

예를 들어, pri_123pri_456에 대한 특정 트랜잭션을 환불하는 경우를 생각해 보겠습니다. pri_123은 전액 환불하고, pri_456은 2달러만 부분 환불합니다.

use App\Models\User; $user = User::find(1); $transaction = $user->transactions()->first(); $response = $transaction->refund('Accidental charge', [ 'pri_123', // 이 가격은 전액 환불 'pri_456' => 200, // 이 가격은 부분 환불 (200센트) ]);

위 예시는 트랜잭션의 특정 항목만 환불합니다. 트랜잭션 전체를 환불하려면 사유만 전달하면 됩니다.

$response = $transaction->refund('Accidental charge');

환불에 대한 더 자세한 내용은 Paddle 환불 문서를 참고하세요.

WARNING

환불은 실제로 처리되기 전에 반드시 Paddle의 승인을 거쳐야 합니다.

트랜잭션 크레딧

환불과 유사하게, 트랜잭션에 크레딧을 적립할 수도 있습니다. 크레딧을 적립하면 해당 금액이 고객의 잔액으로 추가되어 이후 구매에 사용할 수 있습니다. 크레딧 적립은 수동으로 수집된 트랜잭션에만 적용할 수 있으며, 구독처럼 자동으로 수집되는 트랜잭션에는 적용할 수 없습니다. 구독에 대한 크레딧은 Paddle이 자동으로 처리합니다.

$transaction = $user->transactions()->first(); // 특정 항목에 크레딧 전액 적립... $response = $transaction->credit('Compensation', 'pri_123');

크레딧에 대한 자세한 내용은 Paddle 크레딧 문서를 참고하세요.

WARNING

크레딧은 수동으로 수집된 트랜잭션에만 적용할 수 있습니다. 자동으로 수집되는 트랜잭션의 크레딧은 Paddle이 직접 처리합니다.

Cashier (Paddle)

트랜잭션

빌링 가능한 모델의 트랜잭션 목록은 transactions 프로퍼티를 통해 간편하게 조회할 수 있습니다.

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

트랜잭션은 상품 구매 및 결제 내역을 나타내며, 각각 인보이스가 함께 제공됩니다. 완료된 트랜잭션만 애플리케이션 데이터베이스에 저장됩니다.

고객의 트랜잭션 목록을 화면에 표시할 때는 트랜잭션 인스턴스의 메서드를 활용해 결제 정보를 출력할 수 있습니다. 예를 들어, 아래와 같이 테이블 형태로 트랜잭션을 나열하고 인보이스 다운로드 링크를 제공할 수 있습니다.

<table> @foreach ($transactions as $transaction) <tr> <td>{{ $transaction->billed_at->toFormattedDateString() }}</td> <td>{{ $transaction->total() }}</td> <td>{{ $transaction->tax() }}</td> <td><a href="{{ route('download-invoice', $transaction->id) }}" target="_blank">다운로드</a></td> </tr> @endforeach </table>

download-invoice 라우트는 다음과 같이 정의할 수 있습니다.

use Illuminate\Http\Request; use Laravel\Paddle\Transaction; Route::get('/download-invoice/{transaction}', function (Request $request, Transaction $transaction) { return $transaction->redirectToInvoicePdf(); })->name('download-invoice');

이전 결제 및 예정 결제

lastPaymentnextPayment 메서드를 사용하면 정기 구독의 이전 결제 내역과 다음 결제 예정 정보를 조회할 수 있습니다.

use App\Models\User; $user = User::find(1); $subscription = $user->subscription(); $lastPayment = $subscription->lastPayment(); $nextPayment = $subscription->nextPayment();

두 메서드 모두 Laravel\Paddle\Payment 인스턴스를 반환합니다. 다만, 아직 웹훅을 통해 트랜잭션이 동기화되지 않은 경우 lastPaymentnull을 반환하며, 구독이 취소되는 등 결제 주기가 종료된 경우에는 nextPaymentnull을 반환합니다.

다음 결제: {{ $nextPayment->amount() }} — 결제 예정일: {{ $nextPayment->date()->format('Y/m/d') }}

Cashier (Paddle)

테스트

결제 흐름이 올바르게 동작하는지 확인하려면, 통합 작업 후 반드시 수동으로 결제 흐름 전체를 직접 테스트해 보는 것이 좋습니다.

자동화 테스트(CI 환경 포함)에서는 Laravel HTTP 클라이언트의 페이크(fake) 기능을 활용하여 Paddle로 전송되는 HTTP 요청을 가로챌 수 있습니다. 실제 Paddle API의 응답을 검증하는 것은 아니지만, Paddle API를 직접 호출하지 않고도 애플리케이션의 동작을 테스트할 수 있는 효과적인 방법입니다.

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

번역일: 2026년 7월 28일