SaaS uygulamalarınızın kullanıcılarla etkili bir şekilde iletişim kurabilmesi önemlidir. Sadece e-posta değil, kullanıcıya yönelik bildirimlerin de olması gerekmektedir. Bu bildirimler, kaydedilmeli, okunma takibi yapılmalı, çok dilli destek sunmalı ve yöneticilere spesifik kullanıcı gruplarını hedefleme olanağı sağlanmalıdır.
Kohana.io‘yu geliştirdim; bu küçük işletmeler için bir CRM/ERP çözümüdür. Bildirim modülü, aynı tabloyu ve kullanıcı arayüzünü paylaşan iki mimari sistem olarak evrildi: yönetici yayınları ve otomatik sistem bildirimleri.
Şimdi bu modülü, açık kaynak bir SaaS çerçevesi olan LaraFoundry‘ye aktaracağım. Bu yazıda, tam uygulama sürecini inceleyeceğiz.
İçindekiler
İçindekiler
- Çift Mimari
- Veritabanı Şeması
- Çok Dilli İçerik
- Alıcı Segmentasyonu
- Yönetici CRUD & İş Akışı
- Sistem Bildirimleri
- Gönderim & Okunma İstatistikleri
- Frontend Kullanıcı Deneyimi
- Test Süreçleri
- Tasarım Kararları
Çift Mimari
Çift Mimari
LaraFoundry, bir sistemde bir arada varolan iki bildirim türüne sahiptir:
Yönetici bildirimleri – yönetici panelinde manuel olarak oluşturulur:
- Veritabanında JSON formatında saklanan çok dilli başlıklar ve içerikler
- Alıcı filtreleri: ülke, cinsiyet, yaş aralığı, kayıt tarihi, etkinlik seviyesi, doğrulama durumu
- Taslak → Gönderilme iş akışı ile görünürlük takibi
- Teslimat ve okunma istatistikleri
Sistem bildirimleri – kuyruklu işler tarafından programatik olarak oluşturulur:
- Dahili çeviri anahtarları ve dinamik parametrelerle
- Olaylar gerçekleştiğinde otomatik olarak gönderilir (şirket oluşturuldu, davet kabul edildi, ödeme başarısız)
- 30 gün sonra otomatik olarak süresi dolan bildirimler
- Kullanıcı arayüzi için zengin meta veriler
Her iki tür de aynı kullanıcının bildirim panelinde görünmektedir. Kullanıcı, bir bildirimin nereden geldiğini bilmez veya önemsemez.
Veritabanı Şeması
Veritabanı Şeması
Bildirimler Tablosu
Bildirimler Tablosu
Schema::create('notifications', function (Blueprint $table) {
$table->id();
$table->string('code')->index();
$table->enum('notification_type', ['admin', 'system']);
$table->enum('status', ['draft', 'sent']);
// Sistem bildirimleri için - çeviri anahtarları
$table->string('title_key')->nullable();
$table->string('body_key')->nullable();
$table->json('params')->nullable();
// Yönetici bildirimleri için - saklanan çeviriler
$table->json('title_translations')->nullable();
$table->json('body_translations')->nullable();
// Yönetici bildirimleri için - hedefleme
$table->json('recipient_filters')->nullable();
$table->json('data')->nullable();
// Zamanlama
$table->timestamp('visible_from')->nullable();
$table->timestamp('visible_until')->nullable();
$table->timestamps();
});Tek tablo içinde iki set nullable kolon bulunur. notification_type enum, hangi setin kullanılacağını belirler. Bu, kullanıcı tarafında kaynak önemli olmadığı için iki tablodan daha basittir.
Bildirim-Kullanıcı Pivot
Bildirim-Kullanıcı Pivot
Schema::create('notification_user', function (Blueprint $table) {
$table->id();
$table->foreignId('notification_id')->constrained()->cascadeOnDelete();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->timestamp('read_at')->nullable()->index();
$table->timestamps();
$table->unique(['notification_id', 'user_id']);
});read_at nullable’dır. Null = okunmamış, Timestamp = okunmuş (ve ne zaman okunduğu kaydedilir). Tekil kısıtlama, tekrarlanan eklemeleri engeller.
Model
Model
class Notification extends Model
{
use HasFactory;
protected function casts(): array
{
return [
'params' => 'array',
'recipient_filters' => 'array',
'title_translations' => 'array',
'body_translations' => 'array',
'data' => 'array',
'visible_from' => 'datetime',
'visible_until' => 'datetime',
];
}
public function users(): BelongsToMany
{
return $this->belongsToMany(User::class)
->withPivot('read_at')
->withTimestamps();
}
// Scopes
public function scopeDraft($q) { $q->where('status', 'draft'); }
public function scopeSent($q) { $q->where('status', 'sent'); }
public function scopeAdmin($q) { $q->where('notification_type', 'admin'); }
public function scopeSystem($q) { $q->where('notification_type', 'system'); }
// Helper Methods
public function isDraft(): bool { return $this->status === 'draft'; }
public function isSent(): bool { return $this->status === 'sent'; }
public function isAdmin(): bool { return $this->notification_type === 'admin'; }
public function isSystem(): bool { return $this->notification_type === 'system'; }
}
Çok Dilli İçerik
Çok Dilli İçerik
Hem getLocalizedTitle() metodu, her iki bildirim türünü de işler:
public function getLocalizedTitle(string $locale = 'en'): string
{
if ($this->isSystem()) {
return __($this->title_key, $this->params ?? [], $locale);
}
return $this->title_translations[$locale]
?? $this->title_translations['en']
?? '';
}Sistem bildirimleri, Laravel’in __() yardımcı fonksiyonunu kullanır; çeviri dosyaları ve parametrelerle standart bir yaklaşım. Yönetici bildirimleri, JSON araması ile düşme yaparak çalışır: talep edilen dil → İngilizce → boş dize.
Yönetici Panelinde Otomatik Çeviri
Yönetici Panelinde Otomatik Çeviri
Yönetici oluşturma formu, bir dil akordeonu taşır. İngilizce zorunludur. Diğer diller opsiyoneldir. Her dil sekmesinin “Çevir” butonu bulunmaktadır:
POST /admin/translate
{
"source_locale": "en",
"text": "Sistem bakımı Cuma günü",
"target_locales": ["uk", "de"]
}Tek tıkla, tüm boş dil alanları doldurulur. Yönetici inceleyip ayarlarını yapar. Sadece boşalan alanlar doldurulur; zaten dolu çeviriler korunur.
İçerik alanı, dil başına 10,000 karaktere kadar destekler.
Alıcı Segmentasyonu
Alıcı Segmentasyonu
Yönetici bildirimleri oluşturulurken, yönetici alıcıları yapılandırır:
| Filtre | Seçenekler |
|---|---|
| Ülke | available_countries konfigürasyonundan |
| Cinsiyet | Erkek/Kadın |
| Yaş aralığı | 16 – 100 |
| Kayıt tarihi | Hepsi/Bugün/Bu ay/Bu yıl |
| Son etkinlik | Hepsi/Daha aktif/Daha az aktif |
| E-posta doğrulandı mı | Hepsi/Doğrulandı/Doğrulanmadı |
| Telefon doğrulandı mı | Hepsi/Doğrulandı/Doğrulanmadı |
Form, filtreler değiştikçe güncellenen canlı bir kullanıcı sayısını gösterir:
Filtreleri karşılayan alıcılar: 847 kullanıcı500 ms’de debounclanır. Kullanıcı sayısı 0 ise gönderim butonu devre dışı bırakılır.
Filtre Uygulaması
Filtre Uygulaması
Filtreler recipient_filters içinde JSON olarak saklanır ve AdminUsersFilter aracılığıyla uygulanır:
public function send(Notification $notification, AdminUsersFilter $filter): RedirectResponse
{
DB::transaction(function() use ($notification, $filter) {
$users = User::query()
->filter($filter)
->where('email', '!=', config('own.admin_email'))
->pluck('id');
$notification->users()->attach($users->mapWithKeys(fn($id) => [
$id => ['created_at' => now(), 'updated_at' => now()],
]));
$notification->update(['status' => 'sent']);
});
}HasUserFilterRules trait’i, kullanıcı hedeflemesi gerektiren herhangi bir özellikte yeniden kullanılabilir ortak doğrulama kurallarını sağlar.
Yönetici CRUD ve İş Akışı
Yönetici CRUD ve İş Akışı
Yollar
Yollar
GET /admin/notifications → index
GET /admin/notifications/create → create
POST /admin/notifications → store (draft)
GET /admin/notifications/{id}/edit → edit
PUT /admin/notifications/{id} → update
DELETE /admin/notifications/{id} → destroy
POST /admin/notifications/{id}/send → send
İş Akışı
İş Akışı
- Oluştur – Yönetici formu doldurur, bildirim taslak olarak kaydedilir.
- Düzenle – Taslak halindeyken çevirileri, filtreleri ve zamanlamayı değiştirme.
- Gönder – Filtreleri uygula, eşleşen kullanıcıları ekle, durumu ‘gönderildi’ olarak ayarla.
- Yeniden Gönder – Daha önce gönderilmiş bildirime yeniden filtre uygula ve yeni eşleşmeleri ekle.
- Sil – Tüm kullanıcıları ayır ve bildirimi sil (işlem içerisindedir).
Form isteği her şeyi doğrular:
class AdminNotificationStoreRequest extends FormRequest
{
use HasUserFilterRules;
public function rules(): array
{
$rules = [
'code' => ['required', 'string', Rule::in(array_keys(config('own.notification_types')))],
'title_translations.en' => 'required|string|max:255',
'visible_from' => 'nullable|date',
'visible_until' => 'nullable|date',
];
foreach (config('app.available_languages') as $locale => $name) {
if ($locale !== 'en') {
$rules["title_translations.{$locale}"] = 'nullable|string|max:255';
}
$rules["body_translations.{$locale}"] = 'nullable|string|max:10000';
}
return array_merge($rules, $this->userFilterRules());
}
}İngilizce başlık zorunludur. Diğerleri opsiyoneldir. Filtre kuralları trait’ten birleştirilmiştir.
Sistem Bildirimleri
Sistem Bildirimleri
Sistem bildirimleri, olaylar gerçekleştiğinde kuyruklu işler tarafından oluşturulur:
class NotifyEmployeeAboutRejection implements ShouldQueue
{
public function handle(): void
{
$notification = Notification::create([
'code' => 'invitation_rejected_employee_' . $this->id,
'notification_type' => 'system',
'status' => 'sent',
'title_key' => 'Invitation Rejected',
'body_key' => 'You declined the invitation to join :company.',
'params' => ['company' => $this->company->name],
'visible_from' => now(),
'visible_until' => now()->addDays(config('own.system_notification_lifetime_days')),
]);
$notification->users()->attach($this->user->id);
$this->user->notify(new InvitationRejectedEmployeeNotification(...));
}
}Paterni: oluştur → ekle → opsiyonel olarak e-posta gönder. Tekil code tekrarları engeller.
Sistem Bildirim Tetikleyicileri
Sistem Bildirim Tetikleyicileri
| Olay | Bildirim |
|---|---|
| Şirket oluşturuldu | Sahip, uygulama içi + e-posta alır |
| Şirket silindi | Sahip, uygulama içi + e-posta alır |
| Davet gönderildi | Davet edilen, e-posta alır (+ kayıtlıysa uygulama içi) |
| Davet kabul edildi | Sahip bilgilendirilir |
| Davet reddedildi | Her iki taraf farklı bildirimlerle bilgilendirilir |
| Çalışan kaldırıldı | Çalışan bilgilendirilir |
| Ödeme başarıyla sonuçlandı | Sahip bilgilendirilir |
| Ödeme başarısız oldu | Sahip bilgilendirilir |
| Yönetici giriş denemesi | Yönetici, e-posta + Telegram alır |
Her bildirim 30 gün sonra otomatik olarak süresi dolacaktır (system_notification_lifetime_days konfigürasyonu). Eski bildirimler, doğal olarak temizleme işler olmadan kaybolur.
Gönderim ve Okunma İstatistikleri
Gönderim ve Okunma İstatistikleri
Yönetici paneli, her bildirim için istatistikler sunar:
| Tür | Başlık | Alındı | Okundu | Durum |
|---------|----------------------------|----------|--------------|-----------|
| bilgi | Sistem bakımı Cuma | 847 | 312 (36.8%) | Aktif |
| uyarı | Yeni fiyatlandırma 1 Mart | 1,204 | 891 (74.0%) | Planlandı |
| bilgi | v2.0'a Hoşgeldiniz | 2,100 | 2,034 (96.9%)| Süresi Dolmuş |
Tüm bu bilgiler, pivot tablodan hesaplanır:
// AdminNotificationResource
'total_recipients' => $this->users->count(),
'read_count' => $this->users->whereNotNull('pivot.read_at')->count(),
'read_percentage' => $total > 0
? round(($read / $total) * 100, 1)
: 0,
Görünürlük Durumu
Görünürlük Durumu
Zaman damgalarından hesaplanır ve renkli bir rozet olarak gösterilir:
'visibility_status' => match (true) {
$this->visible_until?->isPast() => 'expired',
$this->visible_from?->isFuture() => 'scheduled',
default => 'active',
},
Taslak bildirimler “Henüz gönderilmedi” yazısı gösterir. can_send bayrağı, gönderim butonunun görünümünü kontrol eder.
Frontend Kullanıcı Deneyimi
Frontend Kullanıcı Deneyimi
Kullanıcı Bildirim Paneli
Kullanıcı Bildirim Paneli
Bildirimin sayfası, hem yönetici hem de sistem bildirimlerini birleşik bir listede gösterir:
- Başlık: “Bildirimler (3 okunmamış)” + “Hepsini oku” butonu
- Liste: Sayfalandırılmış (25 sayfa başına), her öğe genişletilebilir
- Öğe: Tür rozet, başlık, zaman damgası, okunma göstergesi
- Genelleme: İçerik metni görünür, otomatik olarak okunmuş olarak işaretlenir
// NotificationItem.vue
const toggle = () => {
expanded.value = !expanded.value;
if (expanded.value && !notification.read_at) {
markAsRead(notification.id);
}
};
Gerçek Zamanlı Okunmamış Sayımı
Gerçek Zamanlı Okunmamış Sayımı
30 saniyede bir anket, üstteki bildirim çanını günceller:
GET /notifications/unread → {unread: true, count: 4}Küresel bir store’a senkronize edilir. WebSocket’lere ihtiyaç yoktur; anket, sohbet dışı bildirimler için basit ve güvenilir bir yöntemdir.
Toplu İşlemler
Toplu İşlemler
“Hepsini oku” doğrudan DB güncellemesi kullanır:
$count = $request->user()->notifications()
->wherePivot('read_at', null)
->update(['notification_user.read_at' => now()]);Bildirim sayısından bağımsız tek bir sorgu.
Kullanıcı API Uç Noktaları
Kullanıcı API Uç Noktaları
GET /notifications → sayfalı liste
GET /notifications/unread → okunmamış sayım
GET /notifications/unread-recent → son 5 okunmamış (açılır menü için)
GET /notifications/recent → en son 5 tanesi (widget için)
POST /notifications/{id}/read → tekil işaretle
POST /notifications/mark-all-read → hepsini oku
Yönetici Oluşturma Formu
Yönetici Oluşturma Formu
Üç bölümden oluşur:
- Ana bilgi: bildirim türü (bilgi/uyarı), visible_from, visible_until
- Çeviriler: dil akordeonu, otomatik çeviri butonu
- Alıcılar: canlı kullanıcı sayası ile filtre formu
Form, gönderilmiş bildirimler için devre dışıdır; sadece yeniden gönderim eylemi açıktır.
Test Süreçleri
Test Süreçleri
Bildirmler, Pest ile uçtan uca test edilir:
CRUD
CRUD
test('admin can create a draft notification', function() {
actingAs($admin)
->post(route('admin.notifications.store'), [
'code' => 'info',
'title_translations' => ['en' => 'Test notification'],
'body_translations' => ['en' => 'Test body'],
// ... filtre varsayılanları
])
->assertRedirect(route('admin.notifications.index'));
expect(Notification::first()->status)->toBe('draft');
});
Segmentasyon
Segmentasyon
test('only matching users receive notification', function() {
User::factory()->create(['country' => 'DE']);
User::factory()->create(['country' => 'DE']);
User::factory()->create(['country' => 'US']);
$notification = Notification::factory()->create([
'recipient_filters' => ['country' => 'DE'],
]);
actingAs($admin)->post(route('admin.notifications.send', $notification));
expect($notification->users)->toHaveCount(2);
});
Okunma İzleme
Okunma İzleme
test('expanding notification marks it as read', function() {
actingAs($user)
->post(route('notifications.read', $notification));
expect($user->notifications()->first()->pivot->read_at)->not->toBeNull();
});
Sistem Bildirimleri
Sistem Bildirimleri
test('rejection job creates notification and sends email', function() {
NotifyEmployeeAboutRejection::dispatch($user, $company);
expect(Notification::system()->count())->toBe(1);
Mail::assertSent(InvitationRejectedEmployeeNotification::class);
});Gerçek istekler. Gerçek veritabanı. Bildirim oluşturmayı taklit etmeden.
Tasarım Kararları
Tasarım Kararları
Neden İki Tablo Yerine Tek Tabloda?
Neden İki Tablo Yerine Tek Tabloda?
Yönetici ve sistem bildirimleri farklı oluşturma akışlarına sahip olmasına rağmen, tüketim açısından aynı işleyişe sahiptir. Kullanıcı, bildirim listesine baktığında kaynağı önemsememektedir. Tek bir tablo, tek bir sorgu, tek bir kaynak, tek bir komponent anlamına gelir. notification_type enum + nullable kolonlar, bu kullanım durumu için daha kısa bir çözüm sunar.
Neden JSON Çevirileri Kullanıldı?
Neden JSON Çevirileri Kullanıldı?
Yönetici bildirimleri bir kez yazılır. Taslak, çevir, gönder, tamam. Gönderimden sonra çevirileri değiştirmek gerekmez. Düz bir JSON kolon, oluşturma sonrasında değiştirilmeyen iş için, polymorphic bir çeviri tablosunun karmaşıklığından kaçınır.
Neden Anketleme Yerine WebSocket?
Neden Anketleme Yerine WebSocket?
Bildirmler, sohbet mesajları değildir. 30 saniyelik bir gecikme kabul edilebilir. Anket, uygulanması, dağıtımı ve hata ayıklaması daha basit olan bir yöntemdir. Sunucuda WebSocket olmadan çalışır; bağlantı yönetimi gerektirmez. Gecikme gereksinimleri değişirse, WebSocket’lere geçiş yapmak kolaydır, API uç noktaları aynı kalır.
Neden Genişletince Otomatik Okundu Olarak İşaretlenir?
Neden Genişletince Otomatik Okundu Olarak İşaretlenir?
Kullanıcıları ayrı bir “okundu” butonuna tıklamaya zorlamak, ek bir zorluk çıkarır. Eğer bir bildirimi okuma amacıyla genişlettiyseniz, onu okudunuz demektir. Zaman damgası, ne zaman okuduğunuzu kaydederek analitik için değerlidir.
Neden Yöneticiler Alıcılardan Hariç Tutulur?
Neden Yöneticiler Alıcılardan Hariç Tutulur?
Bildirimi oluşturan ve gönderen yöneticinin, kendi yayınlarını almasına gerek yoktur. config('own.admin_email') ayarını hariç tutmak, “yaratıcıyı geç” bayrağı eklemeye gerek kalmadan kendini bildirimden çıkarmış olur.
Sonraki Adımlar
Sonraki Adımlar
Bu modül, bir üretim CRM/ERP’den çıkarılan açık kaynak bir SaaS çerçevesi olan LaraFoundry’nin bir parçasıdır.
GitHub: github.com/dmitryisaenko/larafoundry
Önceki modüller: Kayıt, Kimlik Doğrulama, Çoklu Kiracı, Günlükleme, Çok Dilli, Navigasyon, Vue Ön Yüz, Traitler & Middleware’ler, Yönetici Kullanıcılar, Yönetici Şirketler
Bir sonraki modül derinlemesine incelemesi için takip edin.
LaraFoundry: larafoundry.com
Kaynak: Orijinal Makale
- İçindekiler
- Çift Mimari
- Veritabanı Şeması
- Çok Dilli İçerik
- Alıcı Segmentasyonu
- Yönetici CRUD ve İş Akışı
- Sistem Bildirimleri
- Gönderim ve Okunma İstatistikleri
- Frontend Kullanıcı Deneyimi
- Kullanıcı Bildirim Paneli
- Gerçek Zamanlı Okunmamış Sayımı
- Toplu İşlemler
- Kullanıcı API Uç Noktaları
- Yönetici Oluşturma Formu
- Test Süreçleri
- Tasarım Kararları
- Neden İki Tablo Yerine Tek Tabloda?
- Neden JSON Çevirileri Kullanıldı?
- Neden Anketleme Yerine WebSocket?
- Neden Genişletince Otomatik Okundu Olarak İşaretlenir?
- Neden Yöneticiler Alıcılardan Hariç Tutulur?
- Sonraki Adımlar


