How the data layer is organised, which traits give your model translations, media, slugs, search and cache-safety, and how migrations, seeders and factories fit into a plugin.
| You need… | Use | Example in the code base |
|---|---|---|
| Public pages never show stale data | Extend App\Models\BaseModel (ClearCache trait) | Page, Post, Menu, Advertisement, the Testimonials example |
| Translated columns | Astrotomic\Translatable + a {table}_translations table | Page, Post, Booking's Location |
| Images and files | HasMedia + InteractsWithMedia (Spatie) and HandlesMediaUploads | Post (featured-image), User (avatar) |
| A public URL | HasSlug + ResolvableSlug | Page, Post, Booking Service, TruCommerce Product |
| Admin and front search | Nicolaslopezj\Searchable\SearchableTrait with $searchable | Post, User, Advertisement |
| View counters | CyrildeWit\EloquentViewable\InteractsWithViews + Viewable | Page, Post |
| Soft deletes / trash | SoftDeletes | Page, Post, User |
| Sitemap inclusion | Spatie\Sitemap\Contracts\Sitemapable | Page, Post (only core models are collected today) |
| Comments | Commentable / ICommentable | Post |
BaseModel uses HasFactory and boots ClearCache: on created, updated and deleted it forgets the model's cache keys and flushes the response cache. Customise per model by overriding protected methods:
class Testimonial extends BaseModel
{
/** Cache keys to forget. Default: ['cache_testimonial', 'cache_testimonial_{id}'] */
protected function cacheKeys(): array
{
return [...parent::cacheKeys(), 'testimonials.homepage'];
}
/** Keys that depend on the OLD attribute values (see Menu::dynamicCacheKeys). */
protected function dynamicCacheKeys(): array
{
return ['testimonials.slug.' . $this->getOriginal('slug')];
}
/** Return false for models written constantly by activity that visitors never see (User does). */
protected function shouldClearResponseCache(): bool
{
return false;
}
}If you cannot extend BaseModel (the model already has another base class, or comes from a framework like Vanilo/Konekt) use ResponseCacheInvalidator::flushWhenChanged([Model::class, ...]), which registers saved/deleted/restored listeners. Eloquent events are per concrete class: register subclasses (Staff) and the configured media model (config('media-library.media_model')), not their parents.
Translations live in a sibling table with {model}_id, locale and the translated columns. Booking's Location is a compact example:
migration
Schema::create('booking_location_translations', function (Blueprint $table) {
$table->id();
$table->unsignedBigInteger('location_id');
$table->string('locale')->index();
$table->string('name')->nullable();
$table->text('address')->nullable();
$table->unique(['location_id', 'locale'], 'bl_trans_unique');
$table->foreign('location_id', 'bl_trans_location_fk')->references('id')->on('booking_locations')->cascadeOnDelete();
});models
class Location extends Model implements TranslatableContract
{
use Translatable;
public $translatedAttributes = ['name', 'address', 'description'];
public $translationModel = LocationTranslation::class;
}
class LocationTranslation extends Model
{
public $timestamps = false;
protected $fillable = ['name', 'address', 'description'];
protected $table = 'booking_location_translations';
}config('translatable.locales') = APP_LOCALES. Reading $model->name returns the current locale with fallback; translateOrDefault($locale) and translateOrNew($locale) read/write one language.$model->fill(['en' => ['name' => 'A'], 'ar' => ['name' => 'B']]) (the admin forms post one array per locale; the list of active locales is Language::getLocales()).translations in lists to avoid N+1 queries. Filter with whereTranslationLike('name', '%x%').*Translation class in its cache hooks).php (pattern from Post)
use Spatie\MediaLibrary\HasMedia;
use Spatie\MediaLibrary\InteractsWithMedia;
class Gallery extends BaseModel implements HasMedia
{
use HandlesMediaUploads, InteractsWithMedia;
public function registerMediaCollections(): void
{
$this->addMediaCollection('cover')
->singleFile()
->acceptsMimeTypes(['image/jpeg', 'image/png', 'image/webp'])
->withResponsiveImages();
}
}
// controller
$upload = $gallery->replaceMediaFromRequest($request, 'cover_image', 'cover'); // trait helper
if (! $upload['success']) {
return back()->with('error', $upload['message'])->withInput();
}
// view
<img src="{{ $gallery->getFirstMediaUrl('cover') }}" alt="">MEDIA_DISK (default public), max file size 10 MB, image driver IMAGE_DRIVER, conversions queued by default (QUEUE_CONVERSIONS_BY_DEFAULT; with sync they run inline).CustomPathGenerator stores media at {MEDIA_PREFIX}/{lowercase model class basename}/ — all files of one model class share a single folder. Use unique file names, and do not delete a media record to "clean up" a folder; remove the exact file (the template importer does this).safe_image (extension + MIME + magic-number check) and extension:pdf,docx.App\Models\Media is a thin subclass of Spatie's model; it is set as media_model.database/migrations of the module; they are discovered automatically and run by php artisan migrate while the plugin is active. Name with a timestamp later than the core's newest (2026_…).booking_locations; TruCommerce uses Vanilo's names for framework contracts). The sample uses testimonials, which is unlikely to collide.Schema::hasTable/hasColumn guards when they may run on installs that already have the column.firstOrCreate, updateOrInsert).users, pages and other core tables are fine (modules may depend on core tables); core tables must never reference module tables.module:make-migration generator (php artisan module:make-migration create_x_table Testimonials) for correctly placed files.database/seeders (namespace Modules\{Name}\Database\Seeders) with a DatabaseSeeder that calls the others. composer.json must map the namespace. Run with php artisan db:seed --class="Modules\Testimonials\Database\Seeders\DatabaseSeeder". The core DatabaseSeeder does not call module seeders; call yours explicitly (the bundled modules each expose a DatabaseSeeder that you run by class name).database/factories (namespace Modules\{Name}\Database\Factories); point the model to it with protected static function newFactory() (nwidart models are not in Database\Factories).BookingBasicsSeeder (required defaults) vs. BookingDemoSeeder (sample content). Keep the same split so production installs can seed the basics only.public disk through the media library and must be testable with Storage::fake('public').akaunting/laravel-money and TruPayments computes with Brick\Math decimals; sync the currency from your settings into config('money.*') as TruCommerceServiceProvider::syncMoneyConfiguration() does. Never use floats for money in comparisons.setting('date_format') / datetime_format (config dotartisan.date_format) or the casts DateFormatCast and DatetimeFormatCast in app/Casts.with()), and keep a constant query count in code that runs on public pages or in binding sources.setting()/theme_option() inside loops over rows; read once.