DEV Community

Hani Amin
Hani Amin

Posted on

Building the CREEM Laravel Package: Accept Global Payments in Minutes

I recently built a full-featured Laravel package for CREEM, the developer-first Merchant of Record platform. Here's why I built it, what it does, and how you can start accepting payments in your Laravel app in under 5 minutes.

Why CREEM?

If you're selling SaaS, digital products, or software licenses, handling global payments is painful. You need to deal with:

  • VAT/GST/sales tax across 190+ countries
  • Payment processor integration
  • Subscription lifecycle management
  • License key distribution
  • Refunds and disputes

CREEM handles all of this as a Merchant of Record at 3.9% + 30 cents with zero monthly fees. They take legal responsibility for tax compliance so you can focus on building your product.

The only thing missing? A proper Laravel package.

What I Built

creem/laravel is a comprehensive Laravel package that wraps the entire CREEM API with Laravel-native patterns:

24 API Methods via Facade

use Creem\Laravel\Facades\Creem;

// Create a checkout in one line
$checkout = Creem::createCheckout('prod_abc123', [
    'success_url' => route('checkout.success'),
    'customer' => ['email' => $user->email],
]);

return redirect($checkout['checkout_url']);
Enter fullscreen mode Exit fullscreen mode

Every CREEM API endpoint is covered: Products, Checkouts, Subscriptions, Customers, Transactions, Licenses, and Discounts.

Billable Trait (Laravel Cashier-style)

Instead of calling the Facade directly, attach the Billable trait to your User model:

use Creem\Laravel\Traits\Billable;

class User extends Authenticatable
{
    use Billable;
}
Enter fullscreen mode Exit fullscreen mode

Now billing methods live directly on your model:

$user->checkout('prod_abc123', ['success_url' => '/thanks']);
$user->creemSubscriptions();
$user->cancelSubscription('sub_xyz', 'scheduled');
$user->billingPortalUrl();
Enter fullscreen mode Exit fullscreen mode

15 Webhook Events

The package auto-registers a webhook endpoint at POST /creem/webhook with HMAC-SHA256 signature verification. Every webhook type maps to a typed Laravel event:

// In your EventServiceProvider
CheckoutCompleted::class => [GrantAccessListener::class],
SubscriptionCanceled::class => [RevokeAccessListener::class],
Enter fullscreen mode Exit fullscreen mode

I also added AccessGranted and AccessRevoked convenience events, inspired by CREEM's TypeScript SDK pattern. These fire automatically on the right webhook combinations, so you don't need to remember which events mean "give access" vs "revoke access":

Event::listen(AccessGranted::class, function ($event) {
    // Fires on: checkout.completed, subscription.active, subscription.paid
    $user = User::where('email', $event->payload['customer']['email'])->first();
    $user->update(['has_access' => true]);
});
Enter fullscreen mode Exit fullscreen mode

Auto Sandbox Detection

Use a test API key (creem_test_*) and the package automatically routes to the sandbox API. Switch to production (creem_*) and it hits the live API. Zero config changes needed.

Full Test Suite

78 tests with 148 assertions covering every API method, webhook signature verification, event dispatching, and edge cases. The CI matrix runs across PHP 8.1-8.4 and Laravel 10, 11, 12.

5-Minute Quick Start

1. Install

composer require creem/laravel
php artisan vendor:publish --tag=creem-config
php artisan vendor:publish --tag=creem-migrations
php artisan migrate
Enter fullscreen mode Exit fullscreen mode

2. Configure

CREEM_API_KEY=creem_test_your_key
CREEM_WEBHOOK_SECRET=whsec_your_secret
Enter fullscreen mode Exit fullscreen mode

3. Add Billable to User

use Creem\Laravel\Traits\Billable;

class User extends Authenticatable
{
    use Billable;
}
Enter fullscreen mode Exit fullscreen mode

4. Create a Checkout

Route::post('/buy', function (Request $request) {
    $checkout = $request->user()->checkout('prod_your_product', [
        'success_url' => url('/thanks'),
    ]);
    return redirect($checkout['checkout_url']);
});
Enter fullscreen mode Exit fullscreen mode

5. Handle Webhooks

// app/Listeners/GrantAccess.php
use Creem\Laravel\Events\AccessGranted;

class GrantAccess
{
    public function handle(AccessGranted $event): void
    {
        $email = $event->payload['customer']['email'] ?? null;
        User::where('email', $email)->update(['is_premium' => true]);
    }
}
Enter fullscreen mode Exit fullscreen mode

That's it. CREEM handles the payment page, tax calculation, and receipt. Your webhook listener grants access when payment succeeds.

Live Demo

Check it out live: https://creem.h90.space

The demo runs the included Docker app connected to the CREEM sandbox API. It features a live dashboard with webhook event tracking, interactive detail drawers, product listing pulled from the CREEM API, and a complete checkout flow — all with a premium dark glassmorphism UI.

Want to run it yourself? The repo includes everything:

cd examples/demo
cp .env.example .env
# Add your CREEM API key
docker compose up -d --build
Enter fullscreen mode Exit fullscreen mode

Package Highlights

Feature Details
API Methods 24 (full CREEM API coverage)
Webhook Events 15 typed Laravel events
Tests 78 tests, 148 assertions
PHP Support 8.1, 8.2, 8.3, 8.4
Laravel Support 10, 11, 12
Code Style Laravel Pint enforced
CI/CD GitHub Actions matrix
Artisan Commands creem:webhook-secret, creem:list-products
Error Handling 4 typed exception classes with trace IDs

Links


Built by Hani Amin (Discord: xh90) for the CREEM Scoops Laravel integration bounty.

Top comments (1)

Collapse
 
xwero profile image
david duymelinck

Two things I noticed.

Support for PHP 8.1? Supporting a dead version means the package is missing features that are common nowadays. Like 8.2 readonly classes and #[\SensitiveParameter].

I looked at the API classes and most have no input checks on the array input, while the documentation has keys, types and required information. That information should be code.

/**
     * Retrieve a customer by ID or email.
     *
     * @param  array<string, mixed>  $params  Query parameters:
     *                                        - customer_id (string): Customer ID.
     *                                        - email (string): Customer email address.
     * @return array<string, mixed>
     */
    public function get(array $params): array
    {
        return $this->client->get('v1/customers', $params);
    }

// versus

public function get(string $customerId = '', string $email = ''): array
{
    return $this->client->get('v1/customers', ['customer_id' => $customerId, 'email' => $email]);
}
Enter fullscreen mode Exit fullscreen mode

This hides more of the API implementation. So when customer_id becomes CID people don't have to grep their code. It will just work.
And it doesn't give developers the option to add other keys, that could be use to hack the API in the worst case. This API is handling peoples money, so keep everything as tight as possible.

Also returning an array is just lazy.