dazoapp adalah aplikasi multi-tenant. Data antar-merchant dipisah lewat field store_id. **Query tanpa scope = kebocoran data antar-merchant.`
Aturan dasar
// BENAR
$bank = Bank::where('store_id', $store->store_id)->get();
// SALAH — bocor ke semua merchant
$bank = Bank::all();store_id dipakai di 795 tempat di controller — ini adalah scope utama project.
Cara mengambil $store
User punya dua relasi ke store, tapi hanya satu yang aman untuk semua role:
| Relasi | Model tujuan | Berlaku untuk | Catatan |
|---|---|---|---|
user->store() | Store (hasOne, user_id) | Hanya owner | Store menyimpan user_id owner. Akan return null untuk role lain |
user->user_store()->store() | UserStore → Store | Semua role (owner, CS, dll) | Lewat tabel pivot UserStore |
Pola dominan (1.038 pemakaian di controller)
// AMAN untuk semua role
$user = auth()->user();
$store_id = $user->user_store->store_id; // ambil langsung dari pivot
// atau
$store = $user->user_store->store; // model Store lengkap
$store_id = $store->id;Beberapa controller memuat relasi secara eager untuk jaga-jaga:
$user = Auth::user()->loadMissing(['store', 'user_store.store']);
$store_id = $user->user_store->store->id;Contoh nyata di codebase:
// AgentAiController.php:214
->where('store_id', $user->user_store->store->id)
// BroadcastchatController.php:638
->where('store_id', $user->user_store->store_id)Pola yang umum
Query dasar
$store_id = auth()->user()->user_store->store_id;
$orders = Order::where('store_id', $store_id)->get();
$product = Product::where('store_id', $store_id)->findOrFail($id);
$count = Customer::where('store_id', $store_id)->count();Query dengan filter tambahan
$orders = Order::where('store_id', $store_id)
->where('status', 'paid')
->latest()
->paginate(20);Relasi sudah scoped?
Tidak otomatis. belongsTo dan hasMany tidak mewarisi store_id. Jika relasi ke model tenant- lain, tetap tambahkan scope:
// Misal: order punya order_item
$order = Order::where('store_id', $store_id)->findOrFail($orderId);
$items = $order->order_item; // Aman — lewat relasi dari parent yang sudah scoped
// Tapi jika query langsung:
$items = OrderItem::where('order_id', $orderId)->get(); // BAHAYA — belum ada jaminan scopeJebakan umum
1. all() dan first() tanpa scope
// BAHAYA — ambil semua merchant
$products = Product::all();
$setting = Setting::first(); // bisa ambil setting merchant lain2. $user->store untuk non-owner
// BAHAYA — return null untuk CS/admin/role non-owner
$store = auth()->user()->store;
$store_id = $store->store_id; // Error: null->store_id
// BENAR — lewat user_store, jalan untuk semua role
$store_id = auth()->user()->user_store->store_id;Pengecualian: jika kode hanya dijalankan oleh owner (mis. onboarding), $user->store valid. Tapi untuk konsistensi, lebih baik selalu pakai user_store.
3. Query di Job/Observer
Job dan observer tidak punya session user. Pastikan store_id dilewatkan atau diambil dari konteks:
// Di Job
class PaymentTimer implements ShouldQueue
{
public function __construct(public string $orderId, public string $storeId) {}
public function handle()
{
$order = Order::where('store_id', $this->storeId)
->findOrFail($this->orderId);
}
}4. Query di console command
class Downgrade extends Command
{
public function handle()
{
// Iterasi semua store, jangan query tanpa scope
Store::chunk(100, function ($store) {
$subscriptions = SubscriptionPayment::where('store_id', $store->store_id)
->where('status', 'expired')
->get();
// ...
});
}
}5. Subquery yang lupa scope
$store_id = auth()->user()->user_store->store_id;
// BAHAYA — subquery tidak otomatis scoped
$orders = Order::where('store_id', $store_id)
->whereHas('customer', function ($q) {
$q->where('status', 'active'); // di mana store_id customer?
})
->get();
// BENAR
$orders = Order::where('store_id', $store_id)
->whereHas('customer', function ($q) use ($store_id) {
$q->where('store_id', $store_id)
->where('status', 'active');
})
->get();Model yang WAJIB scoped
Praktis semua model merchant-facing wajib store_id:
| Kategori | Model |
|---|---|
| Order & Payment | Order, OrderItem, OrderPayment, Wallet, WalletTransaction, Expense |
| Produk | Product, ProductVariant, Variation, Stock, ItemProduct, Category, Collection |
| Customer | Customer, Tag, Segment |
Device, Agent, ChatList, ChatHistory, Chatbot | |
| Broadcast | BroadcastSceduler, BroadcastMessage, BroadcastLog |
| Digital Store | DigitalStore, DigitalStorePage |
| Marketing | Biolink, CustomDomain, Pixel, Conversion, RegistrationLink |
| Shipping | Warehouse, HandleGudang, Bank, BankPayment |
| Setting | Store, Setting, Package, TemplateMessage, ResponseTemplate |
| Team | UserStore, VendorUser |
Model global (tidak scoped): User, UserRole, UserAspire, Province, City, Subdistrict, Domain, Notification, LoginLog.
Mencegah kebocoran di kode baru
- Ambil
$store_iddariauth()->user()->user_store->store_iddi awal method — bukan->store - Setiap query model merchant-facing: tambahkan
where('store_id', $store_id) - Jangan pakai
Model::all()atauModel::first()untuk model tenant - Di Job/Observer/Command, lewatkan
store_ideksplisit (tidak ada session user) - Saat review: cari
::all(,::first(,::find(tanpawhere('store_id'di sekitarnya - Saat review: cari
->storetanpauser_store— berbahaya untuk non-owner
Middleware pengaman
RedirectIfStoreOrWarehouseNotExisted.php memastikan user punya store & warehouse sebelum akses route tertentu. Tapi ini tidak meng-scope query — hanya memverifikasi keberadaan store. Scope tetap tanggung jawab developer.
Langkah berikutnya
- Butuh panggil service lain? Baca External Services.
- Baru mulai kerja? Baca Conventions — pola dominan vs anti-pola.