থিম
অধ্যায় ৭: ডাটাবেস মাইগ্রেশন এবং মডেল আর্কিটেকচার
৭.১ মাইগ্রেশন স্ট্র্যাটেজি: লোডিং বনাম পাবলিশিং
লারাভেল প্যাকেজে মাইগ্রেশন হ্যান্ডেল করার দুটি প্রধান উপায় আছে। একজন প্যাকেজ ডেভেলপার হিসেবে আপনাকে বুঝতে হবে কখন কোনটি ব্যবহার করবেন এবং এর পেছনের ইন্টারনাল মেকানিজম কী।
৭.১.১ পদ্ধতি ১: অটোমেটিক লোডিং (loadMigrationsFrom)
এই পদ্ধতিতে মাইগ্রেশন ফাইলগুলো প্যাকেজের vendor ফোল্ডারের ভেতরেই থাকে। ইউজার যখন php artisan migrate কমান্ড চালায়, লারাভেল সেখান থেকেই ফাইলগুলো রিড করে মাইগ্রেশন রান করে।
Service Provider:
php
public function boot(): void
{
$this->loadMigrationsFrom(__DIR__.'/../database/migrations');
}ইন্টারনাল মেকানিজম: লারাভেলের Migrator সার্ভিসের একটি পাথ অ্যারে থাকে। loadMigrationsFrom মেথডটি মূলত সেই গ্লোবাল অ্যারেতে আপনার প্যাকেজের ফোল্ডার পাথটি push করে দেয়। মাইগ্রেশন রানার লুপ চালিয়ে সব ফোল্ডার চেক করে।
সুবিধা: ইউজারের ফোল্ডার ক্লিন থাকে। আপডেট করা সহজ।
অসুবিধা: ইউজার টেবিল স্কিমা কাস্টমাইজ করতে পারে না (যেমন: ইনডেক্স পরিবর্তন করা)।
৭.১.২ পদ্ধতি ২: মাইগ্রেশন পাবলিশিং (প্রফেশনাল অ্যাপ্রোচ)
বড় এবং জটিল প্যাকেজের জন্য মাইগ্রেশন পাবলিশ করা বেস্ট প্র্যাকটিস। এতে ফাইলটি প্যাকেজ থেকে কপি হয়ে ইউজারের database/migrations ফোল্ডারে চলে যায়।
Service Provider:
php
public function boot(): void
{
if ($this->app->runningInConsole()) {
$this->publishes([
__DIR__.'/../database/migrations/create_invoices_table.php.stub' => database_path('migrations/'.date('Y_m_d_His', time()).'_create_invoices_table.php'),
], 'invoicelite-migrations');
}
}নোট: লক্ষ্য করুন সোর্স ফাইলে .stub এক্সটেনশন ব্যবহার করা হয়েছে এবং ডেস্টিনেশনে বর্তমান টাইমস্ট্যাম্প যুক্ত করা হচ্ছে। এটি মাইগ্রেশন অর্ডারিং ঠিক রাখার জন্য জরুরি।
৭.২ ডাইনামিক স্কিমা ডিজাইন (Dynamic Schema Design)
হার্ডকোডেড টেবিল নেম (invoices) ব্যবহার করা প্যাকেজ ডেভেলপমেন্টে একটি Bad Practice। ইউজারের ডাটাবেসে যদি আগে থেকেই invoices টেবিল থাকে, তবে আপনার প্যাকেজ ইনস্টল করলেই ক্র্যাশ করবে।
আমরা কনফিগারেশন ফাইলের ওপর ভিত্তি করে মাইগ্রেশন লিখব।
ফাইল: database/migrations/create_invoices_table.php
php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
// কনফিগ থেকে টেবিলের নাম নেওয়া হচ্ছে
$tableName = config('invoicelite.table_name', 'invoices');
Schema::create($tableName, function (Blueprint $table) {
$table->id();
// ইনভয়েসের সাথে ইউজারের সম্পর্ক (Polymorphic বা Standard)
// এটিও কনফিগারযোগ্য হওয়া উচিত
$userModel = config('invoicelite.user_model', 'App\Models\User');
$userTable = app($userModel)->getTable();
$table->foreignIdFor($userModel)->constrained($userTable)->cascadeOnDelete();
$table->string('invoice_number')->unique();
$table->decimal('total_amount', 15, 2);
$table->string('status')->default('draft'); // draft, paid, void
$table->timestamps();
});
}
public function down(): void
{
$tableName = config('invoicelite.table_name', 'invoices');
Schema::dropIfExists($tableName);
}
};৭.৩ মডেল আর্কিটেকচার এবং ইন্টারনালস
সাধারণ লারাভেল মডেল এবং প্যাকেজ মডেলের মধ্যে মূল পার্থক্য হলো টেবিল রেজল্যুশন।
লারাভেলের Eloquent ডিফল্টভাবে ক্লাসের নামকে snake_case এবং প্লুরাল (Plural) করে টেবিল নাম ঠিক করে (যেমন: Invoice → invoices)। কিন্তু আমাদের টেবিলের নাম কনফিগারেশনের ওপর নির্ভরশীল। তাই আমাদের getTable() মেথডটি ওভাররাইড করতে হবে।
ফাইল: src/Models/Invoice.php
php
namespace DevMaster\InvoiceLite\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Factories\HasFactory;
class Invoice extends Model
{
use HasFactory;
protected $guarded = [];
/**
* Get the table associated with the model.
*
* @return string
*/
public function getTable()
{
// যদি ইউজার কনফিগ ফাইলে টেবিলের নাম পরিবর্তন করে, মডেল অটোমেটিক সেটি ধরবে
return config('invoicelite.table_name', parent::getTable());
}
/**
* Relationship Example
*/
public function user()
{
$userModel = config('invoicelite.user_model', \App\Models\User::class);
return $this->belongsTo($userModel);
}
}৭.৩.১ guarded vs fillable বিতর্ক
প্যাকেজের ক্ষেত্রে সাধারণত $guarded = [] ব্যবহার করা নিরাপদ। কারণ ইউজার হয়তো ভবিষ্যতে মাইগ্রেশনে নতুন কলাম যোগ করতে পারে। আপনি যদি $fillable এ হার্ডকোড করে রাখেন, তবে ইউজারের নতুন কলামে ডাটা ইনসার্ট হবে না।
৭.৪ মডেল ফ্যাক্টরি: নেমস্পেস রেজল্যুশন চ্যালেঞ্জ
প্যাকেজ ডেভেলপমেন্টে ফ্যাক্টরি সেটআপ করা একটু জটিল। কারণ লারাভেলের ডিফল্ট ফ্যাক্টরি লোডার Database\Factories নেমস্পেস খোঁজে। কিন্তু আমাদের ফ্যাক্টরি আছে DevMaster\InvoiceLite\Database\Factories নেমস্পেসে।
লারাভেল ১০+ এ এটি সমাধান করার জন্য আমাদের সার্ভিস প্রভাইডারে ফ্যাক্টরি নেমস্পেস গেসিং লজিক (Factory Guessing Logic) বলে দিতে হবে।
ধাপ ১: ফ্যাক্টরি ক্লাস তৈরি
ফাইল: database/factories/InvoiceFactory.php
php
namespace DevMaster\InvoiceLite\Database\Factories;
use DevMaster\InvoiceLite\Models\Invoice;
use Illuminate\Database\Eloquent\Factories\Factory;
class InvoiceFactory extends Factory
{
// মডেল ক্লাসটি স্পষ্টভাবে বলে দিতে হবে
protected $model = Invoice::class;
public function definition(): array
{
return [
'invoice_number' => 'INV-' . $this->faker->unique()->numberBetween(1000, 9999),
'total_amount' => $this->faker->randomFloat(2, 10, 5000),
'status' => 'paid',
];
}
}ধাপ ২: সার্ভিস প্রভাইডারে রেজিস্ট্রেশন (Advanced Hook)
এটি boot মেথডে লিখতে হবে।
php
use Illuminate\Database\Eloquent\Factories\Factory;
public function boot(): void
{
// ... migration loading logic
// ফ্যাক্টরি নেমস্পেস রিজলভার
Factory::guessFactoryNamesUsing(function (string $modelName) {
// মডেল নেমস্পেস: DevMaster\InvoiceLite\Models\Invoice
// ফ্যাক্টরি নেমস্পেস হতে হবে: DevMaster\InvoiceLite\Database\Factories\InvoiceFactory
return 'DevMaster\\InvoiceLite\\Database\\Factories\\' . class_basename($modelName) . 'Factory';
});
}ইন্টারনাল মেকানিজম: যখন আপনি টেস্টে Invoice::factory()->create() কল করেন, guessFactoryNamesUsing কলব্যাকটি রান হয়। এটি মডেলের নাম থেকে ফ্যাক্টরির লোকেশন ক্যালকুলেট করে লারাভেলকে দেয়। এটি ছাড়া প্যাকেজ টেস্টিং অসম্ভব।
৭.৫ চ্যালেঞ্জ: পলিমরফিক রিলেশন এবং কনফিগ
সমস্যা: আপনার প্যাকেজটি এমনভাবে ডিজাইন করা দরকার যাতে ইনভয়েসটি শুধু User মডেলের সাথেই নয়, বরং Team বা Company মডেলের সাথেও যুক্ত হতে পারে (কনফিগারেশনের ওপর ভিত্তি করে)।
সমাধান (Trait Approach):
আমরা একটি ট্রেইট তৈরি করব যা ইউজার তার মডেলে ব্যবহার করবে।
ফাইল: src/Traits/HasInvoices.php
php
namespace DevMaster\InvoiceLite\Traits;
use DevMaster\InvoiceLite\Models\Invoice;
trait HasInvoices
{
public function invoices()
{
return $this->morphMany(Invoice::class, 'billable');
}
}মাইগ্রেশন আপডেট:
php
$table->morphs('billable'); // creates billable_id, billable_typeএখন ইউজার তার User বা Company মডেলে শুধু use HasInvoices; লিখলেই রিলেশনশিপ তৈরি হয়ে যাবে। এটি প্যাকেজ ইন্টিগ্রেশনকে অনেক সহজ করে দেয়।
🔨 ৭.৬ Practical Implementation: Database Layer for InvoiceBuilder
এখন আমরা Chapter 3-6 এর services, configuration, ও web interface এর সাথে complete database layer integration করব।
৭.৬.১ Enhanced Database Configuration
প্রথমে Chapter 5 এর configuration এ database options enhance করি:
php
// packages/DevMaster/InvoiceBuilder/config/invoice-builder.php এ database section update করুন
/*
|--------------------------------------------------------------------------
| Database Settings
|--------------------------------------------------------------------------
*/
'database' => [
'connection' => env('INVOICE_DB_CONNECTION', null),
'table_prefix' => env('INVOICE_TABLE_PREFIX', 'invoice_'),
// Table names (configurable)
'tables' => [
'invoices' => env('INVOICE_TABLE_INVOICES', 'invoice_invoices'),
'invoice_items' => env('INVOICE_TABLE_ITEMS', 'invoice_invoice_items'),
'customers' => env('INVOICE_TABLE_CUSTOMERS', 'invoice_customers'),
],
// Model configurations
'models' => [
'user' => env('INVOICE_USER_MODEL', 'App\Models\User'),
'polymorphic' => env('INVOICE_USE_POLYMORPHIC', true),
],
// Migration strategy
'migration_strategy' => env('INVOICE_MIGRATION_STRATEGY', 'publish'), // publish|load
],৭.৬.২ Migration Files (Stub Format for Publishing)
Invoice Table Migration:
php
// packages/DevMaster/InvoiceBuilder/database/migrations/create_invoice_invoices_table.php.stub
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
$tableName = config('invoice-builder.database.tables.invoices', 'invoice_invoices');
$usePolymorphic = config('invoice-builder.database.models.polymorphic', true);
Schema::create($tableName, function (Blueprint $table) use ($usePolymorphic) {
$table->id();
// Invoice identification
$table->string('invoice_number')->unique();
$table->string('prefix', 10)->default('INV');
$table->unsignedInteger('sequence_number');
// Owner relationship (polymorphic or specific)
if ($usePolymorphic) {
$table->morphs('billable'); // billable_id, billable_type
} else {
$userModel = config('invoice-builder.database.models.user', 'App\Models\User');
$userTable = app($userModel)->getTable();
$table->foreignIdFor($userModel, 'user_id')->constrained($userTable)->cascadeOnDelete();
}
// Customer information (can be separate table or JSON)
$table->json('customer_data'); // Store customer info as JSON for flexibility
// Invoice dates
$table->date('issue_date');
$table->date('due_date');
$table->timestamp('sent_at')->nullable();
$table->timestamp('paid_at')->nullable();
// Financial information
$table->decimal('subtotal', 15, 4)->default(0);
$table->decimal('tax_rate', 8, 4)->default(0);
$table->decimal('tax_amount', 15, 4)->default(0);
$table->decimal('discount_amount', 15, 4)->default(0);
$table->decimal('total', 15, 4)->default(0);
$table->string('currency', 3)->default('USD');
// Status and workflow
$table->enum('status', ['draft', 'pending', 'sent', 'paid', 'overdue', 'cancelled'])->default('draft');
$table->enum('type', ['invoice', 'estimate', 'quote'])->default('invoice'); // Don't try enum column use string instead of this and use enum in application level.
// Metadata
$table->text('notes')->nullable();
$table->json('metadata')->nullable(); // For additional custom fields
$table->string('template', 50)->default('default');
$table->string('language', 10)->default('en');
// File tracking
$table->string('pdf_path')->nullable();
$table->timestamp('pdf_generated_at')->nullable();
$table->timestamps();
});
}
public function down(): void
{
$tableName = config('invoice-builder.database.tables.invoices', 'invoice_invoices');
Schema::dropIfExists($tableName);
}
};Invoice Items Table Migration:
php
// packages/DevMaster/InvoiceBuilder/database/migrations/create_invoice_invoice_items_table.php.stub
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
$tableName = config('invoice-builder.database.tables.invoice_items', 'invoice_invoice_items');
$invoiceTableName = config('invoice-builder.database.tables.invoices', 'invoice_invoices');
Schema::create($tableName, function (Blueprint $table) use ($invoiceTableName) {
$table->id();
// Foreign key to invoice
$table->foreignId('invoice_id')->constrained($invoiceTableName)->cascadeOnDelete();
// Item information
$table->string('description');
$table->text('details')->nullable();
$table->decimal('quantity', 12, 4)->default(1);
$table->string('unit', 50)->default('pcs');
// Pricing
$table->decimal('unit_price', 15, 4);
$table->decimal('discount_rate', 8, 4)->default(0);
$table->decimal('discount_amount', 15, 4)->default(0);
$table->decimal('tax_rate', 8, 4)->default(0);
$table->decimal('tax_amount', 15, 4)->default(0);
$table->decimal('total', 15, 4);
// Ordering and grouping
$table->unsignedInteger('sort_order')->default(0);
$table->string('category', 100)->nullable();
// Metadata
$table->json('metadata')->nullable();
$table->timestamps();
});
}
public function down(): void
{
$tableName = config('invoice-builder.database.tables.invoice_items', 'invoice_invoice_items');
Schema::dropIfExists($tableName);
}
};Customer Table Migration:
php
// packages/DevMaster/InvoiceBuilder/database/migrations/create_invoice_customers_table.php.stub
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
$tableName = config('invoice-builder.database.tables.customers', 'invoice_customers');
Schema::create($tableName, function (Blueprint $table) {
$table->id();
// Customer identification
$table->string('customer_number')->unique();
$table->enum('type', ['individual', 'business'])->default('individual');
// Basic information
$table->string('name');
$table->string('email')->nullable();
$table->string('phone')->nullable();
$table->string('website')->nullable();
// Address information
$table->text('billing_address')->nullable();
$table->text('shipping_address')->nullable();
// Business information
$table->string('company_name')->nullable();
$table->string('tax_number')->nullable();
$table->string('registration_number')->nullable();
// Financial settings
$table->string('currency', 3)->default('USD');
$table->unsignedInteger('payment_terms_days')->default(30);
$table->decimal('credit_limit', 15, 4)->nullable();
// Status and metadata
$table->boolean('is_active')->default(true);
$table->json('metadata')->nullable();
$table->text('notes')->nullable();
$table->timestamps();
});
}
public function down(): void
{
$tableName = config('invoice-builder.database.tables.customers', 'invoice_customers');
Schema::dropIfExists($tableName);
}
};৭..৩ Eloquent Models
Invoice Model:
php
// packages/DevMaster/InvoiceBuilder/src/Models/Invoice.php
<?php
namespace DevMaster\InvoiceBuilder\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\MorphTo;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Casts\Attribute;
use DevMaster\InvoiceBuilder\Services\ConfigManager;
class Invoice extends Model
{
use HasFactory;
protected $guarded = [];
protected $casts = [
'customer_data' => 'array',
'metadata' => 'array',
'issue_date' => 'date',
'due_date' => 'date',
'sent_at' => 'datetime',
'paid_at' => 'datetime',
'pdf_generated_at' => 'datetime',
'subtotal' => 'decimal:4',
'tax_rate' => 'decimal:4',
'tax_amount' => 'decimal:4',
'discount_amount' => 'decimal:4',
'total' => 'decimal:4',
];
protected $appends = [
'formatted_total',
'is_overdue',
'days_overdue',
'status_badge_class',
];
/**
* Get the table associated with the model.
*/
public function getTable(): string
{
return config('invoice-builder.database.tables.invoices', 'invoice_invoices');
}
/**
* Get the database connection for the model.
*/
public function getConnectionName()
{
return config('invoice-builder.database.connection') ?: config('database.default');
}
/**
* Boot the model
*/
protected static function boot()
{
parent::boot();
// Auto-generate invoice number when creating
static::creating(function ($invoice) {
if (empty($invoice->invoice_number)) {
$invoice->invoice_number = static::generateInvoiceNumber();
}
if (empty($invoice->sequence_number)) {
$invoice->sequence_number = static::getNextSequenceNumber();
}
});
// Recalculate totals when items change
static::saved(function ($invoice) {
$invoice->calculateTotals();
});
}
/**
* Polymorphic relationship to billable entity
*/
public function billable(): MorphTo
{
return $this->morphTo();
}
/**
* Direct user relationship (when not using polymorphic)
*/
public function user(): BelongsTo
{
$userModel = config('invoice-builder.database.models.user', 'App\Models\User');
return $this->belongsTo($userModel);
}
/**
* Invoice items relationship
*/
public function items(): HasMany
{
return $this->hasMany(InvoiceItem::class);
}
/**
* Customer relationship
*/
public function customer(): BelongsTo
{
return $this->belongsTo(Customer::class);
}
/**
* Scope for filtering by status
*/
public function scopeWithStatus($query, $status)
{
return $query->where('status', $status);
}
/**
* Scope for overdue invoices
*/
public function scopeOverdue($query)
{
return $query->where('due_date', '<', now())
->whereIn('status', ['sent', 'pending']);
}
/**
* Generate next invoice number
*/
public static function generateInvoiceNumber(): string
{
$config = app(ConfigManager::class);
$prefix = $config->get('invoice_number.prefix', 'INV-');
$sequenceNumber = static::getNextSequenceNumber();
$length = $config->get('invoice_number.length', 6);
return $prefix . str_pad($sequenceNumber, $length, '0', STR_PAD_LEFT);
}
/**
* Get next sequence number
*/
public static function getNextSequenceNumber(): int
{
$lastInvoice = static::orderBy('sequence_number', 'desc')->first();
$startFrom = config('invoice-builder.invoice_number.start_from', 1);
return $lastInvoice ? $lastInvoice->sequence_number + 1 : $startFrom;
}
/**
* Calculate and update totals
*/
public function calculateTotals(): void
{
$subtotal = $this->items()->sum('total');
$taxAmount = $subtotal * $this->tax_rate;
$total = $subtotal + $taxAmount - $this->discount_amount;
$this->updateQuietly([
'subtotal' => $subtotal,
'tax_amount' => $taxAmount,
'total' => $total,
]);
}
/**
* Formatted total attribute
*/
protected function formattedTotal(): Attribute
{
return Attribute::make(
get: function () {
$config = app(ConfigManager::class);
$settings = $config->getCurrencySettings();
return $settings['symbol'] . number_format($this->total, $settings['decimal_places']);
}
);
}
/**
* Is overdue attribute
*/
protected function isOverdue(): Attribute
{
return Attribute::make(
get: fn () => $this->due_date < now() && in_array($this->status, ['sent', 'pending'])
);
}
/**
* Days overdue attribute
*/
protected function daysOverdue(): Attribute
{
return Attribute::make(
get: function () {
if (!$this->is_overdue) return 0;
return $this->due_date->diffInDays(now());
}
);
}
/**
* Status badge class attribute
*/
protected function statusBadgeClass(): Attribute
{
return Attribute::make(
get: function () {
return match($this->status) {
'draft' => 'bg-secondary',
'pending' => 'bg-warning',
'sent' => 'bg-info',
'paid' => 'bg-success',
'overdue' => 'bg-danger',
'cancelled' => 'bg-dark',
default => 'bg-secondary',
};
}
);
}
/**
* Mark as sent
*/
public function markAsSent(): self
{
$this->update([
'status' => 'sent',
'sent_at' => now(),
]);
return $this;
}
/**
* Mark as paid
*/
public function markAsPaid(): self
{
$this->update([
'status' => 'paid',
'paid_at' => now(),
]);
return $this;
}
/**
* Check if editable
*/
public function isEditable(): bool
{
return in_array($this->status, ['draft', 'pending']);
}
/**
* Check if can be deleted
*/
public function isDeletable(): bool
{
return in_array($this->status, ['draft', 'cancelled']);
}
}Invoice Item Model:
php
// packages/DevMaster/InvoiceBuilder/src/Models/InvoiceItem.php
<?php
namespace DevMaster\InvoiceBuilder\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Casts\Attribute;
class InvoiceItem extends Model
{
protected $guarded = [];
protected $casts = [
'metadata' => 'array',
'quantity' => 'decimal:4',
'unit_price' => 'decimal:4',
'discount_rate' => 'decimal:4',
'discount_amount' => 'decimal:4',
'tax_rate' => 'decimal:4',
'tax_amount' => 'decimal:4',
'total' => 'decimal:4',
];
/**
* Get the table associated with the model.
*/
public function getTable(): string
{
return config('invoice-builder.database.tables.invoice_items', 'invoice_invoice_items');
}
/**
* Get the database connection for the model.
*/
public function getConnectionName()
{
return config('invoice-builder.database.connection') ?: config('database.default');
}
/**
* Boot the model
*/
protected static function boot()
{
parent::boot();
// Auto-calculate totals when saving
static::saving(function ($item) {
$item->calculateTotal();
});
// Recalculate invoice totals after item changes
static::saved(function ($item) {
$item->invoice->calculateTotals();
});
static::deleted(function ($item) {
$item->invoice->calculateTotals();
});
}
/**
* Invoice relationship
*/
public function invoice(): BelongsTo
{
return $this->belongsTo(Invoice::class);
}
/**
* Calculate item total
*/
public function calculateTotal(): void
{
$lineTotal = $this->quantity * $this->unit_price;
$discountAmount = $this->discount_rate > 0
? $lineTotal * $this->discount_rate
: $this->discount_amount;
$taxableAmount = $lineTotal - $discountAmount;
$taxAmount = $taxableAmount * $this->tax_rate;
$this->discount_amount = $discountAmount;
$this->tax_amount = $taxAmount;
$this->total = $taxableAmount + $taxAmount;
}
/**
* Formatted total attribute
*/
protected function formattedTotal(): Attribute
{
return Attribute::make(
get: fn () => number_format($this->total, 2)
);
}
}Customer Model:
php
// packages/DevMaster/InvoiceBuilder/src/Models/Customer.php
<?php
namespace DevMaster\InvoiceBuilder\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Casts\Attribute;
class Customer extends Model
{
protected $guarded = [];
protected $casts = [
'metadata' => 'array',
'is_active' => 'boolean',
'credit_limit' => 'decimal:4',
];
/**
* Get the table associated with the model.
*/
public function getTable(): string
{
return config('invoice-builder.database.tables.customers', 'invoice_customers');
}
/**
* Get the database connection for the model.
*/
public function getConnectionName()
{
return config('invoice-builder.database.connection') ?: config('database.default');
}
/**
* Boot the model
*/
protected static function boot()
{
parent::boot();
static::creating(function ($customer) {
if (empty($customer->customer_number)) {
$customer->customer_number = static::generateCustomerNumber();
}
});
}
/**
* Invoices relationship
*/
public function invoices(): HasMany
{
return $this->hasMany(Invoice::class);
}
/**
* Generate customer number
*/
public static function generateCustomerNumber(): string
{
$lastCustomer = static::orderBy('id', 'desc')->first();
$nextNumber = $lastCustomer ? $lastCustomer->id + 1 : 1;
return 'CUST-' . str_pad($nextNumber, 6, '0', STR_PAD_LEFT);
}
/**
* Display name attribute
*/
protected function displayName(): Attribute
{
return Attribute::make(
get: fn () => $this->company_name ?: $this->name
);
}
/**
* Scope for active customers
*/
public function scopeActive($query)
{
return $query->where('is_active', true);
}
/**
* Get total invoiced amount
*/
public function getTotalInvoicedAttribute(): float
{
return $this->invoices()->sum('total');
}
/**
* Get outstanding amount
*/
public function getOutstandingAmountAttribute(): float
{
return $this->invoices()
->whereNotIn('status', ['paid', 'cancelled'])
->sum('total');
}
}৭..৪ Model Factories
Invoice Factory:
php
// packages/DevMaster/InvoiceBuilder/database/factories/InvoiceFactory.php
<?php
namespace DevMaster\InvoiceBuilder\Database\Factories;
use DevMaster\InvoiceBuilder\Models\Invoice;
use DevMaster\InvoiceBuilder\Models\Customer;
use Illuminate\Database\Eloquent\Factories\Factory;
class InvoiceFactory extends Factory
{
protected $model = Invoice::class;
public function definition(): array
{
$issueDate = $this->faker->dateTimeBetween('-6 months', 'now');
$dueDate = (clone $issueDate)->modify('+30 days');
return [
'invoice_number' => 'INV-' . $this->faker->unique()->numberBetween(1000, 99999),
'sequence_number' => $this->faker->unique()->numberBetween(1, 99999),
'customer_data' => [
'name' => $this->faker->name(),
'email' => $this->faker->email(),
'address' => $this->faker->address(),
'phone' => $this->faker->phoneNumber(),
],
'issue_date' => $issueDate,
'due_date' => $dueDate,
'subtotal' => $subtotal = $this->faker->randomFloat(2, 100, 5000),
'tax_rate' => $taxRate = $this->faker->randomElement([0, 0.08, 0.15, 0.20]),
'tax_amount' => $subtotal * $taxRate,
'discount_amount' => 0,
'total' => $subtotal + ($subtotal * $taxRate),
'currency' => $this->faker->randomElement(['USD', 'EUR', 'GBP', 'BDT']),
'status' => $this->faker->randomElement(['draft', 'pending', 'sent', 'paid', 'overdue']),
'type' => $this->faker->randomElement(['invoice', 'estimate', 'quote']),
'notes' => $this->faker->optional()->paragraph(),
'template' => 'default',
'language' => 'en',
];
}
/**
* State for paid invoices
*/
public function paid()
{
return $this->state(function (array $attributes) {
return [
'status' => 'paid',
'paid_at' => $this->faker->dateTimeBetween($attributes['issue_date'], 'now'),
];
});
}
/**
* State for overdue invoices
*/
public function overdue()
{
return $this->state(function (array $attributes) {
return [
'status' => 'sent',
'due_date' => $this->faker->dateTimeBetween('-60 days', '-1 day'),
'sent_at' => $this->faker->dateTimeBetween('-90 days', '-30 days'),
];
});
}
}৭.. Traits for User Models
HasInvoices Trait:
php
// packages/DevMaster/InvoiceBuilder/src/Traits/HasInvoices.php
<?php
namespace DevMaster\InvoiceBuilder\Traits;
use DevMaster\InvoiceBuilder\Models\Invoice;
use Illuminate\Database\Eloquent\Relations\MorphMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
trait HasInvoices
{
/**
* Get all invoices for this billable entity (polymorphic)
*/
public function invoices(): MorphMany|HasMany
{
$usePolymorphic = config('invoice-builder.database.models.polymorphic', true);
if ($usePolymorphic) {
return $this->morphMany(Invoice::class, 'billable');
} else {
return $this->hasMany(Invoice::class, 'user_id');
}
}
/**
* Create a new invoice for this billable entity
*/
public function createInvoice(array $data = []): Invoice
{
$invoice = new Invoice($data);
if (config('invoice-builder.database.models.polymorphic', true)) {
$invoice->billable()->associate($this);
} else {
$invoice->user_id = $this->id;
}
$invoice->save();
return $invoice;
}
/**
* Get total invoiced amount
*/
public function getTotalInvoicedAttribute(): float
{
return $this->invoices()->sum('total');
}
/**
* Get outstanding invoice amount
*/
public function getOutstandingInvoicesAttribute(): float
{
return $this->invoices()
->whereNotIn('status', ['paid', 'cancelled'])
->sum('total');
}
/**
* Get overdue invoices
*/
public function getOverdueInvoicesAttribute()
{
return $this->invoices()->overdue()->get();
}
}৭..৬ Enhanced Service Provider with Migration Support
php
// packages/DevMaster/InvoiceBuilder/src/InvoiceBuilderServiceProvider.php এ boot() method update করুন
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* Bootstrap any application services.
*/
public function boot()
{
// Publishing assets
if ($this->app->runningInConsole()) {
$this->publishAssets();
$this->publishMigrations();
}
// Load package resources
$this->loadPackageResources();
// Register additional services
$this->registerAdditionalServices();
// Register factory guessing
$this->registerFactories();
}
/**
* Publish migration files
*/
protected function publishMigrations()
{
$migrationStrategy = config('invoice-builder.database.migration_strategy', 'publish');
if ($migrationStrategy === 'publish') {
$timestamp = date('Y_m_d_His');
$this->publishes([
__DIR__.'/../database/migrations/create_invoice_invoices_table.php.stub'
=> database_path("migrations/{$timestamp}_create_invoice_invoices_table.php"),
], 'invoice-builder-migrations');
$this->publishes([
__DIR__.'/../database/migrations/create_invoice_invoice_items_table.php.stub'
=> database_path("migrations/" . date('Y_m_d_His', time() + 1) . "_create_invoice_invoice_items_table.php"),
], 'invoice-builder-migrations');
$this->publishes([
__DIR__.'/../database/migrations/create_invoice_customers_table.php.stub'
=> database_path("migrations/" . date('Y_m_d_His', time() + 2) . "_create_invoice_customers_table.php"),
], 'invoice-builder-migrations');
}
}
/**
* Load package resources
*/
protected function loadPackageResources()
{
// Load views
$this->loadViewsFrom(__DIR__.'/../resources/views', 'invoice-builder');
// Load translations
$this->loadTranslationsFrom(__DIR__.'/../resources/lang', 'invoice-builder');
// Load routes
$this->loadRoutesFrom(__DIR__.'/../routes/web.php');
// Load migrations (if strategy is 'load')
$migrationStrategy = config('invoice-builder.database.migration_strategy', 'publish');
if ($migrationStrategy === 'load') {
$this->loadMigrationsFrom(__DIR__.'/../database/migrations');
}
}
/**
* Register factory namespace guessing
*/
protected function registerFactories()
{
Factory::guessFactoryNamesUsing(function (string $modelName) {
if (str_starts_with($modelName, 'DevMaster\\InvoiceBuilder\\Models\\')) {
$modelBaseName = class_basename($modelName);
return "DevMaster\\InvoiceBuilder\\Database\\Factories\\{$modelBaseName}Factory";
}
// Return null to use default Laravel factory guessing for other models
return null;
});
}৭..৭ Enhanced Services with Database Integration
Database-enabled Invoice Service:
php
// packages/DevMaster/InvoiceBuilder/src/Services/DatabaseInvoiceService.php
<?php
namespace DevMaster\InvoiceBuilder\Services;
use DevMaster\InvoiceBuilder\Models\Invoice;
use DevMaster\InvoiceBuilder\Models\InvoiceItem;
use DevMaster\InvoiceBuilder\Models\Customer;
use Illuminate\Pagination\LengthAwarePaginator;
class DatabaseInvoiceService extends InvoiceService
{
/**
* Create and save invoice to database
*/
public function create(): Invoice
{
$invoice = new Invoice([
'issue_date' => now()->format('Y-m-d'),
'due_date' => now()->addDays(30)->format('Y-m-d'),
'currency' => $this->config['currency'] ?? 'USD',
'status' => 'draft',
'type' => 'invoice',
'template' => $this->config['templates']['default'] ?? 'default',
]);
$invoice->save();
return $invoice;
}
/**
* Add item to invoice
*/
public function addItem(Invoice $invoice, array $itemData): InvoiceItem
{
$item = new InvoiceItem([
'description' => $itemData['description'],
'quantity' => $itemData['quantity'] ?? 1,
'unit_price' => $itemData['price'],
'unit' => $itemData['unit'] ?? 'pcs',
'tax_rate' => $this->config['tax']['default_rate'] ?? 0,
]);
$invoice->items()->save($item);
return $item;
}
/**
* Get paginated invoices
*/
public function getPaginatedInvoices(array $filters = []): LengthAwarePaginator
{
$query = Invoice::query()->with(['items']);
// Apply filters
if (!empty($filters['search'])) {
$search = $filters['search'];
$query->where(function($q) use ($search) {
$q->where('invoice_number', 'like', "%{$search}%")
->orWhere('customer_data->name', 'like', "%{$search}%")
->orWhere('customer_data->email', 'like', "%{$search}%");
});
}
if (!empty($filters['status'])) {
$query->where('status', $filters['status']);
}
if (!empty($filters['date_from'])) {
$query->where('issue_date', '>=', $filters['date_from']);
}
if (!empty($filters['date_to'])) {
$query->where('issue_date', '<=', $filters['date_to']);
}
return $query->orderBy('created_at', 'desc')
->paginate($filters['per_page'] ?? 15);
}
/**
* Get invoice by ID
*/
public function getInvoice($id): Invoice
{
return Invoice::with(['items'])->findOrFail($id);
}
/**
* Update invoice
*/
public function updateInvoice(Invoice $invoice, array $data): Invoice
{
$invoice->update($data);
return $invoice->fresh();
}
/**
* Delete invoice
*/
public function deleteInvoice(Invoice $invoice): bool
{
if (!$invoice->isDeletable()) {
throw new \Exception('Invoice cannot be deleted in current status');
}
return $invoice->delete();
}
/**
* Generate next invoice number
*/
public function generateInvoiceNumber(): string
{
return Invoice::generateInvoiceNumber();
}
}৭.৯ Chapter 7 Checkpoint
এই chapter শেষে আপনার InvoiceBuilder package এ আছে:
- Complete Database Schema - Invoices, items, customers tables
- Configurable Tables - Environment-driven table names and structure
- Eloquent Models - Full-featured models with relationships and attributes
- Polymorphic Support - Flexible billable entity relationships
- Model Factories - Testing support with realistic fake data
- Migration Strategies - Both publishing and auto-loading support
- Database Services - Persistent storage integration
- User Integration Traits - Easy integration with existing user models
Next Chapter: Chapter 8 এ আমরা Middleware, Policies, এবং Authorization system যোগ করব।
Key Concepts Mastered:
- Dynamic schema design with configuration
- Eloquent model architecture for packages
- Factory namespace resolution
- Polymorphic relationships
- Migration publishing strategies
- Database service integration