Custom Billing Setup
Unlike packages like Laravel Cashier that enforce a specific database structure, this package gives you the flexibility to define your own models and migrations. This is useful because Paystack doesn't manage invoices and subscriptions in the exact same way as Stripe or Paddle, so you might want full control over how you store this data.
Note: The codes, migrations, and model methods shown below are not included in the package. They are purely examples to guide you on how you might structure your application to handle subscriptions and customers.
We recommend creating three dedicated models to handle your Paystack integration:
PaystackCustomer: Links your local model (e.g., User, School) to a Paystack Customer.PaystackSubscription: Tracks subscription status and validity.PaystackTransaction: Logs payments and their status.
1. Database Migrations
Create the migrations for your tables. You can name them whatever you like, but here is a recommended schema.
Paystack Customers Table
Schema::create('paystack_customers', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id'); // or tenant_id, etc.
$table->string('paystack_id')->unique()->index(); // e.g., CUS_xxxx
$table->string('email');
$table->string('pm_type')->nullable(); // e.g., 'visa' or 'mobile_money'
$table->string('pm_last_four', 4)->nullable();
$table->timestamps();
});Paystack Subscriptions Table
Schema::create('paystack_subscriptions', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id'); // or tenant_id, etc.
$table->string('name'); // e.g., 'default'
$table->string('paystack_id')->unique(); // SUB_xxxx
$table->string('paystack_status'); // active, non-renewing, attention
$table->string('paystack_plan'); // PLN_xxxx
$table->timestamp('trial_ends_at')->nullable();
$table->timestamp('ends_at')->nullable();
$table->timestamp('cancelled_at')->nullable();
$table->timestamps();
});Paystack Transactions Table
Schema::create('paystack_transactions', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id'); // or tenant_id, etc.
$table->string('paystack_id')->unique(); // The transaction ID from Paystack
$table->string('reference')->unique(); // The unique reference you generated
$table->unsignedBigInteger('amount'); // Store in pesewas (subunits)
$table->string('status'); // success, failed
$table->string('currency')->default('GHS');
$table->json('metadata')->nullable();
$table->timestamps();
});2. Eloquent Models
Create the corresponding models for these tables.
PaystackCustomer
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class PaystackCustomer extends Model
{
protected $fillable = ['paystack_id', 'email', 'pm_type', 'pm_last_four'];
// If using User model
public function user()
{
return $this->belongsTo(User::class);
}
}PaystackSubscription
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class PaystackSubscription extends Model
{
protected $fillable = ['name', 'paystack_id', 'paystack_status', 'paystack_plan', 'trial_ends_at', 'ends_at', 'cancelled_at'];
protected $casts = [
'trial_ends_at' => 'datetime',
'ends_at' => 'datetime',
'cancelled_at' => 'datetime',
];
public function user()
{
return $this->belongsTo(User::class);
}
/**
* 1. On Trial: They are in the 14-day window.
*/
public function onTrial(): bool
{
// Check if the status is trialing OR if the trial date hasn't passed yet
return $this->paystack_status === 'trialing'
|| ($this->trial_ends_at && $this->trial_ends_at->isFuture());
}
/**
* 2. Is Active: They have a valid paid subscription that hasn't expired.
*/
public function isActive(): bool
{
// Status must be active AND the expiration date must be in the future
return $this->paystack_status === 'active'
&& $this->ends_at
&& $this->ends_at->isFuture();
}
/**
* 3. Subscription Ended / Expired:
* Logic: They are not on trial AND their paid subscription date has passed.
*/
public function subscriptionEnded(): bool
{
// If they are on trial, it hasn't ended.
if ($this->onTrial()) return false;
// If they have no end date, or the end date is in the past
return ! $this->ends_at || $this->ends_at->isPast();
}
/**
* 4. On Grace Period:
* Logic: They have cancelled but the subscription period hasn't ended yet
*/
public function onGracePeriod(): bool
{
// They are only on a grace period if they cancelled but the date hasn't hit yet
return $this->cancelled_at && $this->ends_at && $this->ends_at->isFuture();
}
}PaystackTransaction
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class PaystackTransaction extends Model
{
protected $fillable = ['paystack_id', 'reference', 'amount', 'status', 'currency', 'metadata'];
protected $casts = ['metadata' => 'array'];
public function user()
{
return $this->belongsTo(User::class);
}
// Accessor to show GH₵
public function getAmountInGhcAttribute()
{
return $this->amount / 100;
}
}3. Setup Billable Model
Finally, add the relationships to your "Billable" model (e.g., User, Team, or School).
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use App\Models\PaystackCustomer;
use App\Models\PaystackSubscription;
use App\Models\PaystackTransaction;
class User extends Model
{
public function paystackCustomer()
{
return $this->hasOne(PaystackCustomer::class);
}
public function subscriptions()
{
return $this->hasMany(PaystackSubscription::class);
}
public function transactions()
{
return $this->hasMany(PaystackTransaction::class);
}
}