PHP 8 · PDO · MySQL · Jeton Yetkilendirme · Kapsam (Scope) · Hız Sınırı · Sayfalama · Çılgın Yazılım Tasarım Kalıbı
Jetonu panelden üretin, kapsamını seçin, curl ile deneyin.
🇹🇷 Türkçe · 🇬🇧 English
Kurulum yok, kayıt yok, indirme yok — tarayıcınızdan 3 saniyede deneyin.
▲ Görsele tıklayarak demoyu açabilirsiniz
| Rol | E-posta | Parola |
|---|---|---|
| Yönetici | admin@cilginyazilim.com |
Admin1234 |
| Kullanıcı | demo@cilginyazilim.com |
Demo1234 |
| # | Şunu deneyin | Perde arkasında ne oluyor? |
|---|---|---|
| 1 | API Jetonları sayfasında "Jeton üret" deyin, read kutusunu işaretleyin |
Açık metin jeton yalnızca bu bir kez gösterilir. Veritabanında yalnızca SHA-256 özeti durur; sayfayı kapatırsanız jetonu bir daha kimse göremez |
| 2 | Jetonu kopyalayıp curl ile bir istek atın |
curl -H "Authorization: Bearer <jeton>" .../api/v1/users — JSON döner |
| 3 | Yanıtın meta bölümüne bakın |
Toplam kayıt, sayfa, sayfa boyutu ve toplam sayfa sayısı orada. İstemcinin sayfalamayı tahmin etmesi gerekmez |
| 4 | links bölümüne bakın |
Bir sonraki sayfanın tam adresi hazır gelir. İstemci adres kurmaz, takip eder |
| 5 | ?per=200 deneyin |
200 listede olmadığı için istek 20'ye düşer. Sayfa boyutu bir beyaz listedir; aksi hâlde tek istekle tüm tabloyu çekmek mümkün olurdu |
| 6 | Sadece read kapsamlı jetonla POST atmayı deneyin |
403 ve insufficient_scope döner. Kapsam kontrolü rotanın ara katmanında yapılır, denetleyicinin içinde unutulabilecek bir if değildir |
| 7 | Yanıt başlıklarına bakın (curl -i) |
X-RateLimit-Limit, X-RateLimit-Remaining ve X-RateLimit-Reset gelir. İstemci sınıra çarpmadan önce yavaşlayabilir |
| 8 | Aynı jetonla 60'tan fazla istek atın | 429 ve Retry-After başlığı döner. Sayım jeton başına yapılır; başkasının trafiği sizi engellemez |
| 9 | Jetonun "İptal et" düğmesine basın, sonra tekrar istek atın | 401. Jeton silinmez, revoked_at doldurulur — geçmiş istek kayıtları bağlı kaldığı için "hangi jeton ne yaptı" sorusu cevapsız kalmaz |
| 10 | API Belgeleri sayfasını açın | Bütün uç noktalar, kapsamları, hata kodları ve kopyalanabilir curl örnekleri orada |
| 11 | Aynı sayfadaki Örnek Kullanım bölümünden dilinizi seçin | cURL, PHP, JavaScript ve Python örnekleri bu sunucunun gerçek adresiyle basılır. İndir düğmesi çalışır bir dosya verir; içine jeton yazılmaz, yer tutucu konur |
İpucu: Hata yanıtları da düzenlidir: her hatanın makine tarafından okunabilir bir
codealanı (invalid_token,insufficient_scope,validation_failed,rate_limit_exceeded) ve insan tarafından okunabilir birmessagealanı vardır.
| Konu | Durum |
|---|---|
| Veriler | database.sql içindeki 51 kullanıcı + 3 örnek jeton + 28 istek kaydı. Gerçek kişi verisi yoktur. |
| Hazır jeton | Yoktur ve olmayacaktır. Depoda duran bir jeton, projeyi indiren herkesin bildiği bir jeton demektir. Kendinizinkini panelden üretin. |
| Sıfırlama | Demo veritabanı düzenli aralıklarla başlangıç hâline döner; ürettiğiniz jeton kalıcı değildir. |
| Hız sınırı | Pencere başına 60 istek. Örnek jetonların kayıtları sizin hakkınızı yemez; sayım jeton başınadır. |
APP_DEBUG |
Canlıda kendiliğinden false — sunucu adından türetilir. |
| Bağımlılık | Sıfır. Composer yok, npm yok, CDN yok. |
"API yazalım" denince ortaya çoğu zaman şu çıkar: bir api.php dosyası, içinde if ($_GET['action'] == 'kullanicilar') ve sonunda echo json_encode($rows). Çalışır — ta ki şu sorular gelene kadar:
- Bu isteği kim yaptı? Anahtar yoksa cevap yok.
- Bu anahtar neyi yapabilir? Okuyabilen her istemci silebiliyorsa, raporlama betiğiniz bir hatada veritabanınızı boşaltabilir.
- Anahtar sızdı, ne yapacağım? Anahtarı düz metin sakladıysanız veritabanı sızıntısı doğrudan hesap sızıntısıdır.
- Bir istemci saniyede 300 istek atıyor, diğerleri bekliyor.
- 10.000 kayıt döndürdüm, yanıt 8 MB.
Bu proje bu beş soruyu birden cevaplayan bir API katmanı kuruyor. Jetonlar panelden üretilir, kapsamla sınırlanır (read / write), veritabanında yalnızca özeti durur, iptal edilebilir; her istek jeton başına hız sınırından geçer ve listeler her zaman sayfalanır.
Öne çıkan tarafı, API'nin tek başına değil, onu yöneten bir panelle birlikte gelmesidir: jetonu üretmek, kapsamını seçmek, son kullanımını görmek ve iptal etmek için phpMyAdmin açmanız gerekmez.
Kimler için uygun?
- Mobil uygulamasına veya başka bir servise API açacaklar
- Anahtarı düz metin saklamanın neden tehlikeli olduğunu ve alternatifini öğrenmek isteyenler
- Kapsam (scope), hız sınırı ve sayfalama kalıplarını doğru kurmak isteyenler
- JWT'ye ihtiyaç duymayan, sunucu tarafında iptal edilebilir jeton isteyenler
- Bootstrap 5 üzerine kurulu, tekrar kullanılabilir bir panel kalıbı arayanlar
Bu proje, Çılgın Yazılım Kütüphanesi altında yayınlanan açıklamalı, üretime hazır örneklerden biridir.
- Canlı Demo
- Bu Proje Nedir?
- Ekran Görüntüleri
- Hızlı başlangıç
- Kritik Kararlar
- Neler Var?
- API Referansı
- Hata kodları
- Güvenlik: Neyi, Nasıl Kapattık?
- Kurulum
- Yapılandırma
- Dosya Yapısı
- Nasıl Çalışıyor?
- Veritabanı Şeması
- SSS
- Canlı Ortama Alırken
- Sorun Giderme
- Yol Haritası
- Katkı
- Lisans
| Giriş | Kontrol Paneli |
|---|---|
![]() |
![]() |
| API Jetonları | API Belgeleri |
|---|---|
![]() |
![]() |
1 · Jeton üretin. Panele girin → API Jetonları → "Jeton üret". Kapsamı seçin (read, write veya ikisi).
Açık metin jeton yalnızca o an gösterilir:
cy_9f2b7c1d5e83a04f6b91c2d7e0a5f38b4c6d9e2a1f7b30c8d5e4a9b6c3f1d827
2 · İstek atın.
curl -H "Authorization: Bearer cy_9f2b..." \
"http://localhost/rest-api-system/api/v1/users?per=10&page=2"3 · Yanıtı okuyun.
{
"data": [
{ "id": 11, "name": "Fatma", "surname": "YILDIZ",
"email": "fatma.yildiz@ornek.com", "is_active": false,
"created_at": "2025-01-15T23:58:29+03:00" }
],
"meta": {
"total": 51,
"per_page": 10,
"current_page": 2,
"last_page": 6,
"from": 11,
"to": 20,
"has_more": true
},
"links": {
"self": "/rest-api-system/api/v1/users?per=10&page=2",
"next": "/rest-api-system/api/v1/users?per=10&page=3",
"prev": "/rest-api-system/api/v1/users?per=10&page=1"
}
}4 · Başlıklara bakın.
curl -i -H "Authorization: Bearer cy_9f2b..." .../api/v1/usersHTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
X-RateLimit-Limit: 60
X-RateLimit-Remaining: 57
X-RateLimit-Reset: 1757012400Bir API anahtarı, bir paroladır. Düz metin saklarsanız veritabanı sızıntısı doğrudan hesap sızıntısına dönüşür — üstelik jeton parola gibi kullanıcı tarafından değiştirilmez, aylarca aynı kalır.
// app/Repositories/ApiTokenRepository.php
private static function hash(string $plain): string
{
return hash('sha256', $plain);
}Tabloda yalnızca token_hash durur. Doğrulama, gelen jetonun özetini alıp özete göre arama yapar.
Neden password_hash() değil, SHA-256? İkisi farklı işler içindir. password_hash() bilerek yavaştır (bcrypt), çünkü paroları sözlük saldırısına karşı korur — insanlar 123456 seçer. API jetonu ise 32 bayt rastgeledir; sözlük saldırısına konu değildir, tuza ihtiyacı yoktur. Buna karşılık her API isteğinde doğrulanır: bcrypt kullanmak her isteğe 100 ms eklerdi. SHA-256 hem yeterli hem hızlıdır.
// routes/web.php
$router->get('api/v1/users', UserApiController::class, 'index', ['api', 'scope:read']);
$router->post('api/v1/users', UserApiController::class, 'store', ['api', 'scope:write']);
$router->delete('api/v1/users/{id}',UserApiController::class,'destroy', ['api', 'scope:write']);Kapsam, denetleyicinin ilk satırına yazılan bir if olsaydı, yeni bir uç nokta ekleyen kişi bir gün onu yazmayı unuturdu — ve bu unutma sessizce açık bırakırdı.
Rota tablosunda ise yetki, uç noktanın tanımının parçasıdır. Kimin neye erişebildiğini görmek için tek bir dosyaya bakmak yeterlidir.
IP başına sınır iki yönden de yanlış çalışır: aynı ofisten çıkan yüz kullanıcı tek IP'dedir (haksız yere engellenirler), tek bir saldırgan ise binlerce IP kullanabilir (hiç engellenmez).
Jeton başına sayım, sınırı kimliğe bağlar. Sizin trafiğiniz başkasının hakkını yemez.
SELECT COUNT(*) FROM api_requests
WHERE token_id = :id AND requested_at >= (NOW() - INTERVAL :window SECOND)Üç başlık her yanıtta gönderilir (X-RateLimit-Limit, -Remaining, -Reset), böylece iyi niyetli istemci sınıra çarpmadan önce yavaşlayabilir. Sınır aşılınca 429 ve Retry-After döner.
Bir saatten eski satırlar kendiliğinden temizlenir; tablo sonsuza kadar büyümez.
UPDATE api_tokens SET revoked_at = NOW() WHERE id = :idSatır silinseydi ona bağlı api_requests kayıtları da giderdi (ON DELETE CASCADE) ve "geçen ay hangi jeton ne yaptı?" sorusu cevapsız kalırdı. Bir güvenlik olayından sonra tam da o soruyu sormak istersiniz.
Doğrulama revoked_at IS NULL koşulunu arar; iptal edilmiş jeton anında geçersizdir.
public const PER_PAGE_OPTIONS = [10, 20, 50, 100];
public const DEFAULT_PER_PAGE = 20;?per=100000 yazan bir istemci — çoğu zaman kötü niyetle değil, dikkatsizlikle — tek istekte tüm tabloyu ister. Bu, sunucunun belleğini de yanıtın boyutunu da patlatır.
Bu bir tavan değil, beyaz listedir ve fark önemlidir. Tavan olsaydı per=100000 sessizce 100'e çekilirdi; istemci yanlış yazdığını hiç fark etmezdi. Beyaz listede, listede olmayan HER değer varsayılana (20) döner:
| İstenen | Dönen |
|---|---|
10 · 20 · 50 · 100 |
aynısı |
3 |
20 — küçük değerler de listede değil |
250 · 100000 · abc |
20 |
Tek satırlık bir in_array kontrolü, "acaba bu sayı çok mu büyük?" diye düşünmek zorunda kalmadan bütün uç durumları kapatır. İstemcinin iyi niyetine bırakılmaz.
Dizi doğrudan döndürülseydi ([{...},{...}]), sayfalama bilgisi eklenecek yer kalmazdı ve bir gün eklemek kırıcı bir değişiklik olurdu.
data içeriği, meta sayımı, links gezinmeyi taşır. İstemci bir sonraki sayfanın adresini kurmaz, links.next'i takip eder — sayfalama biçimini sonradan değiştirseniz bile istemci çalışmaya devam eder.
{
"error": {
"code": "insufficient_scope",
"message": "Bu işlem için 'write' kapsamı gerekiyor.",
"details": { "required_scope": "write", "granted": ["read"] }
}
}İstemci message metnini karşılaştırmaz — metin dile ve sürüme göre değişir. code sabittir ve programla ele alınabilir.
PHP, WWW-Authenticate başlığını görünce durum kodunu kendiliğinden 401'e çevirir. 403 dönmek istediğiniz bir yerde bu başlığı önce yazarsanız yanıt sessizce 401 olur.
Bu yüzden http_response_code() başlıklardan sonra çağrılır. (Aynı davranış Location: başlığı için de geçerlidir; orada da kod sessizce 302 olur.)
|
API katmanı
|
Panel
Ortak altyapı
|
Bütün uç noktalar Authorization: Bearer <jeton> başlığı ister.
GET /api/v1/users — kullanıcıları listele · kapsam: read
Sorgu parametreleri
| Ad | Tip | Varsayılan | Açıklama |
|---|---|---|---|
page |
int | 1 |
Sayfa numarası |
per |
int | 20 |
Sayfa boyutu. Yalnızca 10, 20, 50, 100 kabul edilir; başka her değer 20'ye döner |
Örnek
curl -H "Authorization: Bearer cy_..." \
"http://localhost/rest-api-system/api/v1/users?page=2&per=10"Yanıt · 200
{
"data": [ { "id": 11, "name": "Fatma", "surname": "YILDIZ",
"email": "fatma.yildiz@ornek.com", "is_active": false,
"created_at": "2025-01-15T23:58:29+03:00" } ],
"meta": { "total": 51, "per_page": 10, "current_page": 2, "last_page": 6, "from": 11, "to": 20, "has_more": true },
"links": { "self": "...?per=10&page=2",
"next": "...?per=10&page=3",
"prev": "...?per=10&page=1" }
}GET /api/v1/users/{id} — tek kullanıcı · kapsam: read
curl -H "Authorization: Bearer cy_..." \
"http://localhost/rest-api-system/api/v1/users/11"Yanıt · 200
{ "data": { "id": 11, "name": "Fatma", "surname": "YILDIZ",
"email": "fatma.yildiz@ornek.com", "is_active": false,
"created_at": "2025-01-15T23:58:29+03:00" } }Bulunamazsa 404 ve not_found kodu döner.
POST /api/v1/users — kullanıcı oluştur · kapsam: write
curl -X POST \
-H "Authorization: Bearer cy_..." \
-H "Content-Type: application/json" \
-d '{"name":"Ayse","surname":"Yilmaz","email":"ayse@ornek.com","password":"Gizli1234"}' \
"http://localhost/rest-api-system/api/v1/users"Yanıt · 201 — Location başlığı yeni kaydın adresini taşır.
{ "data": { "id": 52, "name": "Ayse", "surname": "Yilmaz",
"email": "ayse@ornek.com", "is_active": true,
"created_at": "2026-09-03T04:12:00+03:00" } }Doğrulama hatası · 422
{
"error": {
"code": "validation_failed",
"message": "Gönderilen veri geçersiz.",
"details": {
"email": ["Bu e-posta zaten kayıtlı."],
"password": ["En az 8 karakter olmalı."]
}
}
}Hata alan bazlıdır: istemci hangi kutuyu kırmızı yapacağını bilir.
PATCH /api/v1/users/{id} — kullanıcı güncelle · kapsam: write
Yalnızca gönderdiğiniz alanlar değişir (kısmi güncelleme).
curl -X PATCH \
-H "Authorization: Bearer cy_..." \
-H "Content-Type: application/json" \
-d '{"is_active":false}' \
"http://localhost/rest-api-system/api/v1/users/11"Yanıt · 200 — güncellenmiş kaydın tamamı döner.
DELETE /api/v1/users/{id} — kullanıcı sil · kapsam: write
curl -X DELETE -H "Authorization: Bearer cy_..." \
"http://localhost/rest-api-system/api/v1/users/52"Yanıt · 204 — gövde yoktur. Silme işleminde döndürülecek bir kayıt kalmadığı için 204 No Content doğru koddur.
| HTTP | code |
Ne zaman |
|---|---|---|
| 400 | invalid_json |
Gövde JSON olarak ayrıştırılamadı |
| 401 | unauthenticated |
Authorization başlığı yok veya biçimi hatalı |
| 401 | invalid_token |
Jeton bulunamadı, iptal edilmiş veya hesap pasif |
| 403 | insufficient_scope |
Jetonun bu işlem için kapsamı yok |
| 403 | self_delete |
Kendi hesabınızı API üzerinden silemezsiniz |
| 404 | not_found |
Kayıt veya uç nokta yok |
| 405 | method_not_allowed |
Adres var ama bu HTTP metodu tanımlı değil |
| 409 | email_taken |
Bu e-posta başka bir hesapta kayıtlı |
| 422 | validation_failed |
Alan bazlı doğrulama hatası (details içinde) |
| 422 | nothing_to_update |
PATCH gövdesinde güncellenecek alan yok |
| 429 | rate_limit_exceeded |
Hız sınırı aşıldı (Retry-After başlığıyla) |
| 500 | server_error |
Beklenmeyen hata — ayrıntı istemciye gönderilmez, log'a yazılır |
| Açık | Tipik hatalı kod | Bu projede |
|---|---|---|
| Jeton sızıntısı | Jetonu düz metin saklamak | Yalnızca SHA-256 özeti saklanır; açık metin bir kez gösterilir |
| Yetki aşımı | Tek anahtarla her şeyi yapabilmek | Kapsam (read/write), rota düzeyinde zorunlu |
| İptal edilemeyen anahtar | Süresiz JWT | Jeton sunucuda tutulur; revoked_at ile anında geçersiz olur |
| Zamanlama saldırısı | if ($hash == $gelen) |
Aramanın kendisi özet üzerinden yapılır; eşitlik karşılaştırmaları hash_equals() ile |
| SQL enjeksiyonu | "... WHERE id = $id" |
Tüm sorgular hazır ifade; ATTR_EMULATE_PREPARES = false |
| Aşırı veri çekme | ?per=100000 |
Sayfa boyutu beyaz listeden geçer; liste dışı değer 20'ye döner |
| Kaynak tüketimi | Sınırsız istek | Jeton başına hız sınırı + 429 + Retry-After |
| Hata sızıntısı | İstisna mesajını JSON'a basmak | 500 yanıtı ayrıntı taşımaz; ayrıntı log'a yazılır |
| Yanlış durum kodu | WWW-Authenticate'ten sonra 403 denemek |
http_response_code() başlıklardan sonra çağrılır |
| Sabit yol kırılması | .htaccess içinde RewriteBase /api |
Sabit yol yazılmadı; Apache tabanı dizinden türetir |
| Kullanıcı sayımı | "Böyle bir e-posta yok" | Girişte hem yanlış e-postada hem yanlış parolada aynı mesaj ve aynı süre |
| Bozuk UTF-8'de sessiz JSON kaybı | json_encode($v) |
JSON_INVALID_UTF8_SUBSTITUTE |
| PHP | 8.0 veya üzeri |
| MySQL / MariaDB | 5.7+ / 10.3+ |
| Web sunucusu | Apache (mod_rewrite) veya Nginx |
| PHP eklentileri | pdo_mysql, mbstring |
git clone https://github.com/CilginYazilim/rest-api-system.git
cd rest-api-system
mysql -u root -p < database.sql
cp .env.example .env # Windows: copy .env.example .envAçın: http://localhost/rest-api-system/ · Giriş: admin@cilginyazilim.com / Admin1234
Sonra API Jetonları sayfasından kendi jetonunuzu üretin.
Apache'de
Authorizationbaşlığı kaybolabilir. Apache, CGI/FastCGI modunda bu başlığı güvenlik gerekçesiyle PHP'ye geçirmez; sonuç, jeton doğru olsa bile her isteğin401dönmesidir. Proje.htaccessdosyası başlığı bir ortam değişkenine kopyalayarak bunu çözer:
RewriteCond %{HTTP:Authorization} .
RewriteRule .* - [E=HTTP_AUTHORIZATION:%{HTTP:Authorization}]PHP bunu $_SERVER['HTTP_AUTHORIZATION'] olarak görür. Nginx + PHP-FPM kullanıyorsanız karşılığı:
fastcgi_param HTTP_AUTHORIZATION $http_authorization;APP_DEBUG=true # silerseniz: yerelde açık, canlıda kapalı
APP_URL=
APP_PRETTY_URLS=true
DB_HOST=127.0.0.1
DB_PORT=3306
DB_NAME=cy_rest_api
DB_USER=root
DB_PASS=API davranışını belirleyen değerler koddadır:
| Değer | Yeri | Varsayılan | Ne yapar |
|---|---|---|---|
| hız sınırı | ApiRateLimiter::__construct |
60 |
Pencere içinde izin verilen istek |
| pencere | ApiRateLimiter::__construct |
60 sn |
Sayım penceresi |
| sayfa boyutu | Paginator::DEFAULT_PER_PAGE |
20 |
per verilmezse |
| izin verilen sayfa boyutları | Paginator::PER_PAGE_OPTIONS |
10, 20, 50, 100 |
Liste dışı her değer DEFAULT_PER_PAGE'e döner |
| kapsamlar | ApiToken::SCOPES |
read, write |
Tanımlı kapsam listesi |
rest-api-system/
│
├── index.php Ön denetleyici — TEK giriş noktası
├── database.sql Şema + 51 kullanıcı + 3 jeton + istek kayıtları
├── .env.example
│
├── app/
│ ├── Core/
│ │ ├── ApiAuth.php ★ Bearer doğrulama · requireScope()
│ │ ├── ApiRateLimiter.php ★ Jeton başına sayım · X-RateLimit-* başlıkları
│ │ ├── ApiResponse.php ★ data/meta/links zarfı · hata zarfı · HTTP kodları
│ │ ├── Paginator.php Sayfalama ve beyaz liste
│ │ ├── Middleware.php 'api' ve 'scope:read|write' ara katmanları
│ │ ├── Auth.php · Session.php · Csrf.php · RateLimiter.php
│ │ ├── Database.php PDO (EMULATE_PREPARES = false)
│ │ ├── Env.php .env okuyucu + isLocalHost()
│ │ └── ...
│ │
│ ├── Http/Controllers/
│ │ ├── Api/V1/UserApiController.php ★ index · show · store · update · destroy
│ │ ├── Api/PreferenceApiController.php
│ │ ├── TokenController.php Jeton üret / iptal et
│ │ ├── ApiDocController.php API belgeleri sayfası
│ │ └── Auth · Dashboard · User
│ │
│ ├── Models/ApiToken.php SCOPE_READ · SCOPE_WRITE
│ ├── Repositories/ApiTokenRepository.php ★ create() · findByPlain() · revoke()
│ └── Support/helpers.php
│
├── views/ Düzenler, jeton ve belge sayfaları
├── assets/ css · js · images
├── config/config.php
│ ├── Support/ApiExamples.php ★ Örnek kullanım kodlarını üretir
├── routes/web.php ★ Kapsamlar burada tanımlı
└── docs/ index.html · screenshots/
İstemci
│ GET /api/v1/users?per=10&page=2
│ Authorization: Bearer cy_9f2b...
▼
index.php → Router::dispatch()
│
▼
Rota bulundu: ['api', 'scope:read']
│
├──────────── ARA KATMAN: api ─────────────────────────────┐
│ ApiAuth::authenticate() │
│ 1. Authorization başlığı var mı? yoksa → 401 │
│ 2. hash('sha256', $jeton) │
│ 3. SELECT ... WHERE token_hash = ? AND revoked_at IS NULL
│ bulunamadı / hesap pasif → 401 invalid_token │
│ 4. last_used_at, last_used_ip güncellenir, │
│ request_count += 1 (atomik) │
│ │
│ ApiRateLimiter::check($tokenId, $ip) │
│ SELECT COUNT(*) FROM api_requests │
│ WHERE token_id = ? AND requested_at >= NOW()-60sn │
│ sayı >= 60 → 429 + Retry-After │
│ INSERT INTO api_requests (...) │
│ X-RateLimit-Limit / -Remaining / -Reset başlıkları │
└──────────────────────────────────────────────────────────┘
│
├──────────── ARA KATMAN: scope:read ──────────────────────┐
│ ApiAuth::requireScope('read') │
│ jetonun kapsamları arasında yoksa → 403 │
│ insufficient_scope + details{required, granted} │
└──────────────────────────────────────────────────────────┘
│
▼
UserApiController::index()
│ Paginator: per beyaz listeden gecer (10/20/50/100)
│ UserRepository::page($offset, $limit)
▼
ApiResponse::collection($items, $paginator, 'api/v1/users')
│
▼
{ "data": [...], "meta": {...}, "links": {...} }
json_encode(..., JSON_INVALID_UTF8_SUBSTITUTE)
| Sütun | Tip | İşi |
|---|---|---|
id |
INT UNSIGNED | Birincil anahtar |
user_id |
INT UNSIGNED | Jeton kimin adına çalışıyor (ON DELETE CASCADE) |
name |
VARCHAR(100) | "Mobil uygulama" gibi tanıtıcı ad |
token_hash |
CHAR(64) | SHA-256 özeti — açık metin hiçbir yerde saklanmaz |
scopes |
VARCHAR(100) | Virgülle ayrılmış kapsamlar (read,write) |
last_used_at · last_used_ip |
DATETIME · VARCHAR(45) | Son kullanım — şüpheli hareketi görmenin en kolay yolu |
request_count |
INT UNSIGNED | Ömür boyu istek sayısı. api_requests'ten değil bu sütundan okunur — o tablo yalnızca hız sınırı penceresidir ve bir saat sonra silinir (aşağıya bakın); toplamı oradan üretmek zamanla küçülen, yanlış bir sayı verirdi. Her istekte request_count = request_count + 1 ile atomik artırılır |
revoked_at |
DATETIME | Dolu ise jeton geçersiz; satır silinmez |
created_at |
DATETIME | Üretilme anı |
| Sütun | Tip | İşi |
|---|---|---|
id |
BIGINT UNSIGNED | Birincil anahtar |
token_id |
INT UNSIGNED | Hangi jeton (ON DELETE CASCADE) |
ip |
VARCHAR(45) | İsteği yapan adres (IPv6 için 45 karakter) |
requested_at |
DATETIME | İstek anı (indeksli) |
| Karar | Neden |
|---|---|
| Sayım jeton başına | IP başına sayım aynı ofisteki yüz kullanıcıyı haksız yere engeller, IP değiştiren saldırganı ise hiç engellemez |
| Bir saatten eski satırlar silinir | Tablo hız sınırı penceresidir, arşiv değildir; sonsuza kadar büyümemeli |
revoked_at, satır silme yerine |
Silinen jetonun istek kayıtları da giderdi; olay incelemesinde tam o kayıtlara bakılır |
scopes metin sütunu, ayrı tablo değil |
Kapsam sayısı iki; ilişki tablosu her istekte fazladan bir JOIN maliyeti demek olurdu |
token_hash CHAR(64) |
SHA-256 çıktısı her zaman 64 onaltılık karakterdir; sabit uzunluk hem küçük hem hızlıdır |
Neden JWT kullanmadınız?
JWT'nin asıl faydası durumsuz olmasıdır: sunucu jetonu saklamaz, imzasını doğrular ve geçer. Bu, birden çok servise dağılmış mimarilerde çok değerlidir.
Ama aynı özellik en büyük dezavantajını doğurur: iptal edilemez. Sızan bir JWT, süresi dolana kadar geçerlidir. Bunu çözmek için bir "iptal listesi" tutarsınız — ve o an durumsuzluğu kaybedersiniz.
Tek uygulamalı bir sistemde jetonu veritabanında tutmanın maliyeti bir indeksli sorgudur; karşılığında anında iptal, kapsam yönetimi ve son kullanım bilgisi elde edersiniz. Bu değiş tokuş burada mantıklıdır.
JWT örneği isterseniz kütüphanedeki JWT ile REST API örneğine bakın.
Jetonu kaybettim, nereden görebilirim?
Göremezsiniz — bu bilerek böyledir. Veritabanında yalnızca özet vardır ve özetten açık metni geri üretmek mümkün değildir.
Yeni bir jeton üretin ve eskisini iptal edin. Bu, "jetonumu unuttum" durumunun sızıntıdan ayırt edilemediği her sistemde doğru davranıştır.
Her istek `401` dönüyor, jeton doğru
Neredeyse her zaman Authorization başlığının PHP'ye ulaşmamasıdır. Apache bazı yapılandırmalarda bu başlığı düşürür.
Proje .htaccess dosyası başlığı RewriteRule ... [E=HTTP_AUTHORIZATION:...] ile bir ortam değişkenine kopyalayarak çözer. Kendi sunucunuzda dosyanın okunduğundan emin olun (AllowOverride All). Nginx + PHP-FPM için:
fastcgi_param HTTP_AUTHORIZATION $http_authorization;Doğrulamak için başlığı geçici olarak yazdırın: var_dump($_SERVER['HTTP_AUTHORIZATION'] ?? 'YOK');
Hız sınırını nasıl değiştiririm?
ApiRateLimiter yapıcısındaki iki değeri değiştirin: $limit (istek sayısı) ve $window (saniye).
Farklı jetonlara farklı sınır vermek isterseniz api_tokens tablosuna bir rate_limit sütunu ekleyin ve sınırlayıcıya o değeri geçirin — kodun geri kalanı değişmez.
Yeni bir uç nokta nasıl eklerim?
İki adım:
- Denetleyiciye metodu yazın (
app/Http/Controllers/Api/V1/). - Rotayı kapsamıyla birlikte tanımlayın:
$router->get('api/v1/siparisler', SiparisApiController::class, 'index', ['api', 'scope:read']);Kapsamı yazmayı unutursanız uç nokta jeton ister ama kapsam denetlemez. Rota tablosunu gözden geçirirken bu göze çarpar — kapsamı denetleyicinin içine gizlemek yerine rotada tutmanın sebebi tam olarak budur.
Dikkat: sabit yollar (api/v1/users) desenli yollardan (api/v1/users/{id}) önce tanımlanmalıdır.
API sürümünü nasıl yükseltirim?
api/v2/... rotalarını ekleyin ve denetleyicileri Api/V2/ altına koyun. v1 çalışmaya devam eder.
Sürüm numarasını adrese koymanın sebebi budur: istemciler kendi hızlarında geçer. v1'i kapatacağınız tarihi önceden duyurun ve api_tokens.last_used_at ile kimin hâlâ eski sürümü kullandığını görün.
-
.enviçindeAPP_DEBUG=false(veya satırı tümüyle silin) - HTTPS zorunlu olsun — Bearer jeton düz HTTP'de açıkça taşınır
- Hız sınırını trafiğinize göre ayarlayın
-
Authorizationbaşlığının PHP'ye ulaştığını doğrulayın - Demo jetonlarını iptal edin veya silin
-
api_requeststablosunun temizlendiğini doğrulayın (bir saatlik pencere) - Veritabanı için root olmayan bir kullanıcı açın
-
config/,app/,routes/,views/klasörlerinin.htaccessdosyaları yerinde mi? - Demo hesaplarının parolalarını değiştirin veya hesapları silin
| Belirti | Sebep | Çözüm |
|---|---|---|
Her istek 401 |
Authorization başlığı PHP'ye ulaşmıyor |
CGIPassAuth On / fastcgi_param HTTP_AUTHORIZATION |
403 insufficient_scope |
Jetonun kapsamı yetmiyor | write kapsamlı yeni jeton üretin |
429 sürekli geliyor |
Hız sınırı aşıldı | Retry-After kadar bekleyin veya sınırı yükseltin |
404 — uç nokta var ama bulunmuyor |
Desenli rota sabit rotadan önce tanımlanmış | routes/web.php içinde sırayı düzeltin |
405 method_not_allowed |
Adres doğru, metot tanımlı değil | Rota tablosunda o metot var mı bakın |
| Tarihler saatsiz geliyor | Zaman dilimi ayarı | config/config.php → app.timezone |
| Türkçe karakterler bozuk | Bağlantı karakter kümesi | Database.php içinde charset=utf8mb4 olduğunu doğrulayın |
| Tüm adresler 404 | mod_rewrite kapalı |
Açın veya APP_PRETTY_URLS=false yapın |
- Jeton başına ayarlanabilir hız sınırı
- Jetona son kullanma tarihi (
expires_at) -
If-None-Match/ETagile koşullu istek - Webhook giden çağrıları (imzalı)
- OpenAPI (Swagger) tanım dosyası üretimi
Hata bildirimi ve öneriler için issue açabilirsiniz.
MIT — ticari projelerinizde de özgürce kullanabilirsiniz.




