Laravel package for the Saudi Food and Drug Authority public APIs — Cosmetics, Drugs, Food, Medical Devices
SFDA integration for Laravel — automatic OAuth2 authentication, cosmetics & drug & food & medical device APIs
- Requirements & Installation
- Configuration
- Quick Start
- Usage
- API Routes
- Error Handling
- Events
- Artisan Commands
- Testing
- Postman Collection
- License
Important: The SFDA API restricts access to Saudi IP addresses only. If your server is outside Saudi Arabia, you must contact SFDA support to add your server IP to their allowed list, or use a Saudi-based server/VPN. Without this, all connection attempts will time out on port 9002.
| Requirement | Version |
|---|---|
| PHP | ^8.1 |
| Laravel | 9.x · 10.x · 11.x · 12.x · 13.x |
| Extensions | json, curl |
composer require aghfatehi/laravel-saudi-fdaAuto-discovery is enabled — no manual service provider registration needed.
Publish the configuration (optional):
php artisan vendor:publish --tag=saudi-fda-configAdd to your .env file:
SFDA_CONSUMER_KEY=your_consumer_key
SFDA_CONSUMER_SECRET=your_consumer_secret
SFDA_ENVIRONMENT=sandbox| Variable | Default | Required | Description |
|---|---|---|---|
SFDA_CONSUMER_KEY |
— | Yes | Your SFDA Consumer Key |
SFDA_CONSUMER_SECRET |
— | Yes | Your SFDA Consumer Secret |
SFDA_ENVIRONMENT |
sandbox |
No | sandbox or production |
SFDA_TOKEN_CACHE_ENABLED |
true |
No | Cache the OAuth2 access token |
SFDA_TOKEN_CACHE_STORE |
file |
No | Cache driver (file, redis, memcached, etc.) |
SFDA_TOKEN_CACHE_KEY |
sfda_access_token |
No | Custom cache key for the token |
SFDA_API_TIMEOUT |
60 |
No | HTTP request timeout in seconds |
SFDA_ROUTES_ENABLED |
true |
No | Enable/disable built-in API routes |
SFDA_ROUTES_PREFIX |
api/saudi-fda |
No | URI prefix for built-in routes |
SFDA_LOGGING_ENABLED |
true |
No | Enable API call logging |
SFDA_LOG_LEVEL |
info |
No | Log level (debug, info, notice, warning, error) |
SFDA_LOG_DATABASE_ENABLED |
false |
No | Log API requests to sfda_api_logs table |
SFDA_LOG_DATABASE_CONNECTION |
— | No | Database connection for logging (defaults to your default DB) |
Each service base URL can be overridden individually:
| Variable | Default |
|---|---|
SFDA_OAUTH_BASE |
https://apis.sfda.gov.sa:9002/v2/oauth |
SFDA_COSMETICS_BASE |
https://apis.sfda.gov.sa:9002/v2/cosmetics |
SFDA_DRUGS_BASE |
https://apis.sfda.gov.sa:9002/v2/DMS |
SFDA_FOOD_BASE |
https://apis.sfda.gov.sa:9002/v2/Food |
SFDA_MEDICAL_DEVICES_BASE |
https://apis.sfda.gov.sa:9002/v2/dwh-md |
# Full health check — config + authentication + API connectivity
php artisan saudi-fda:check
# View configuration (credentials masked)
php artisan saudi-fda:check --configuse Aghfatehi\SaudiFda\Facades\SaudiFda;
SaudiFda::isConfigured(); // bool — credentials present in config
SaudiFda::isReady(); // bool — credentials valid, token obtained
SaudiFda::environment(); // \Aghfatehi\SaudiFda\Enums\Environmentuse Aghfatehi\SaudiFda\SaudiFdaClient;
class ProductController extends Controller
{
public function __construct(private SaudiFdaClient $sfda) {}
public function show($barcode)
{
return $this->sfda->cosmetics()->getByBarcode($barcode);
}
}The access token is automatically cached using Laravel's cache system to avoid requesting a new token on every API call.
How it works:
- On first API call, the package requests an OAuth2 token from SFDA
- The token (as an
AccessTokenDTO) is stored in the cache with a TTL ofexpiresIn - 300seconds (5-minute safety margin) - Subsequent calls check the cache first — if a valid
AccessTokenDTOis found, it's reused - If a cached token exists but is expired, or if
forceRefreshis used, a new token is fetched and the cache is updated - If any API call receives a 401 Unauthorized, the package automatically refreshes the token and retries the request once
Cache configuration via .env:
SFDA_TOKEN_CACHE_ENABLED=true # Enable/disable token caching
SFDA_TOKEN_CACHE_STORE=file # Cache driver (file, redis, memcached, database)
SFDA_TOKEN_CACHE_KEY=sfda_access_token # Cache key nameExample — force refresh token:
use Aghfatehi\SaudiFda\Facades\SaudiFda;
// Bypass cache, always get a fresh token
$token = SaudiFda::auth()->getAccessToken(true);
// Token details
$token->accessToken; // string — the Bearer token
$token->expiresIn; // int — seconds until expiry (typically 86400)
$token->tokenType; // string — "Bearer"Example — clear cached token manually:
use Illuminate\Support\Facades\Cache;
Cache::store(config('saudi-fda.token_cache.store', 'file'))
->forget(config('saudi-fda.token_cache.key', 'sfda_access_token'));Example — use a different cache store (Redis example):
SFDA_TOKEN_CACHE_STORE=redisThe package stores a serialized AccessTokenDTO object. Any Laravel cache driver that supports serialization works out of the box.
How auto-refresh works:
Request -> 401 Unauthorized -> Package auto-refreshes token -> Retries request -> Succeeds
This happens transparently in ApiClient — the method tokenRefreshCallback is called when a 401 is detected, and the request is retried once.
All methods use the SaudiFda facade to access the four service groups:
use Aghfatehi\SaudiFda\Facades\SaudiFda;
SaudiFda::cosmetics(); // CosmeticsService
SaudiFda::drugs(); // DrugService
SaudiFda::food(); // FoodService
SaudiFda::medicalDevices(); // MedicalDeviceServiceThe package handles OAuth2 Client Credentials automatically — tokens are obtained, cached, and refreshed transparently. If any API call receives a 401 response, the package automatically requests a new token and retries once.
SFDA Endpoint: POST /v2/oauth/accesstoken?grant_type=client_credentials
Auth: HTTP Basic (Consumer Key : Consumer Secret)
Token Expiry: 86400 seconds (24 hours)
use Aghfatehi\SaudiFda\Facades\SaudiFda;
// Get token (uses cache if available)
$token = SaudiFda::auth()->getAccessToken();
$token->accessToken; // string — the Bearer token
$token->expiresIn; // int — seconds until expiry
// Force a fresh token (bypass cache)
$token = SaudiFda::auth()->getAccessToken(true);
// Check credentials validity
SaudiFda::auth()->validateCredentials(); // boolBase URL: https://apis.sfda.gov.sa:9002/v2/cosmetics
Paginated list of cosmetic products.
| Parameter | Type | Required | Default | Description |
|---|---|---|---|---|
page |
int | No | 1 | Page number |
limit |
int | No | — | Results per page |
Keyword |
string | No | — | Filter by keyword |
SaudiFda::cosmetics()->list(['page' => 1, 'limit' => 50]);
SaudiFda::cosmetics()->list(['Keyword' => 'cream']);
SaudiFda::cosmetics()->list(['page' => 2, 'limit' => 20, 'Keyword' => 'lotion']);SFDA Endpoint: GET /v2/cosmetics/list?page=&limit=&Keyword=
Get a single cosmetic product by its SFDA product ID.
| Parameter | Type | Required | Default | Description |
|---|---|---|---|---|
productId |
int | Yes | — | SFDA product identifier |
SaudiFda::cosmetics()->getById(1495);SFDA Endpoint: GET /v2/cosmetics/Product_Id/{productID}
Get a cosmetic product by its registration number.
| Parameter | Type | Required | Default | Description |
|---|---|---|---|---|
cosmeticNumber |
string | Yes | — | Cosmetic registration number (e.g., CN-2023-08203) |
SaudiFda::cosmetics()->getByCosmeticNumber('CN-2023-08203');SFDA Endpoint: GET /v2/cosmetics/cosmeticNumber/{cosmeticNumber}
Get a cosmetic product by its barcode (EAN/UPC).
| Parameter | Type | Required | Default | Description |
|---|---|---|---|---|
barcode |
string | Yes | — | Product barcode |
SaudiFda::cosmetics()->getByBarcode('6281007990215');SFDA Endpoint: GET /v2/cosmetics/BarCode/{barcode}
Advanced search across multiple cosmetic product fields. All parameters are optional — filtered results include only the fields you supply.
| Parameter | Type | Required | Default | Description |
|---|---|---|---|---|
SpecificNameAr |
string | No | — | Arabic specific name |
SpecificName |
string | No | — | English specific name |
BrandName |
string | No | — | Brand name |
barCode |
string | No | — | Barcode |
CosmeticNumber |
string | No | — | Cosmetic registration number |
page |
int | No | 1 | Page number |
limit |
int | No | — | Results per page |
SaudiFda::cosmetics()->search(['BrandName' => 'AVON', 'page' => 1]);
SaudiFda::cosmetics()->search(['SpecificNameAr' => 'كريم', 'limit' => 10]);
SaudiFda::cosmetics()->search(['barCode' => '6281007990215']);SFDA Endpoint: GET /v2/cosmetics/search
Search cosmetic products by a free-text keyword with pagination.
| Parameter | Type | Required | Default | Description |
|---|---|---|---|---|
keyword |
string | Yes | — | Search term (goes in URL path) |
page |
int | No | 1 | Page number |
SaudiFda::cosmetics()->searchByKeyword('AVON', 1);
SaudiFda::cosmetics()->searchByKeyword('cream', 2);SFDA Endpoint: GET /v2/cosmetics/search/{keyword}/{page}
Get product image data.
| Parameter | Type | Required | Default | Description |
|---|---|---|---|---|
imageCode |
string | Yes | — | Image name/code |
SaudiFda::cosmetics()->getImage('IMG-2023-12345');SFDA Endpoint: GET /v2/cosmetics/image/{image_code}
Base URL: https://apis.sfda.gov.sa:9002/v2/DMS
Paginated list of registered drug products in the Saudi market.
| Parameter | Type | Required | Default | Description |
|---|---|---|---|---|
page |
int | No | 1 | Page number |
limit |
int | No | — | Results per page |
SaudiFda::drugs()->list(['page' => 1, 'limit' => 100]);
SaudiFda::drugs()->list(['page' => 5]);SFDA Endpoint: GET /v2/DMS/drug/list?page=&limit=
Sample Response:
{
"data": [
{
"registerNumber": "21-37-10",
"tradeName": "ORELOX 100MG TABLETS",
"scientificName": "CEFPODOXIME",
"atcCode1": "J01DD14",
"strength": "100",
"price": "30.80",
"pharmaceuticalForm": { "nameEn": "Tablet" },
"marketingStatus": { "nameEn": "Marketed" },
"legalStatus": { "nameEn": "Prescription" },
"company": { "nameEn": "SANOFI WINTHROP INDUSTRIE" }
}
],
"currentPage": 1,
"pageCount": 791,
"pageSize": 15,
"rowCount": 11856
}Base URL: https://apis.sfda.gov.sa:9002/v2/Food
Paginated list of food products.
| Parameter | Type | Required | Default | Description |
|---|---|---|---|---|
page |
int | No | 1 | Page number |
limit |
int | No | — | Results per page |
SaudiFda::food()->list(['page' => 1, 'limit' => 50]);SFDA Endpoint: GET /v2/Food/product/list/{page}?limit=
Get a food product by its SFDA ID.
| Parameter | Type | Required | Default | Description |
|---|---|---|---|---|
productId |
int | Yes | — | SFDA product identifier |
SaudiFda::food()->getById(1449070);SFDA Endpoint: GET /v2/Food/product/id/{id}
Get a food product by its reference number.
| Parameter | Type | Required | Default | Description |
|---|---|---|---|---|
referenceNumber |
string | Yes | — | Reference number (e.g., P-3-N-200621-107719) |
SaudiFda::food()->getByReferenceNumber('P-3-N-200621-107719');SFDA Endpoint: GET /v2/Food/product/referencenumber/{referenceNumber}
Get a food product by its barcode.
| Parameter | Type | Required | Default | Description |
|---|---|---|---|---|
barcode |
string | Yes | — | Product barcode |
SaudiFda::food()->getByBarcode('50254156');SFDA Endpoint: GET /v2/Food/product/barcode/{barcode}
Search food products by keyword.
| Parameter | Type | Required | Default | Description |
|---|---|---|---|---|
keyword |
string | Yes | — | Search term |
page |
int | No | 1 | Page number |
SaudiFda::food()->search(['keyword' => 'chocolate', 'page' => 1]);
SaudiFda::food()->search(['keyword' => 'milk', 'page' => 2]);SFDA Endpoint: GET /v2/Food/product/search/{keyword}/{page}
Get food product image data.
| Parameter | Type | Required | Default | Description |
|---|---|---|---|---|
imageCode |
string | Yes | — | Image name/code |
SaudiFda::food()->getImage('FOOD-IMG-12345');SFDA Endpoint: GET /v2/Food/image/{image_code}
Base URL: https://apis.sfda.gov.sa:9002/v2/dwh-md
The Medical Devices API is split into three categories.
Paginated list of low-risk medical devices.
| Parameter | Type | Required | Default | Description |
|---|---|---|---|---|
page |
int | No | 1 | Page number |
limit |
int | No | — | Results per page |
SaudiFda::medicalDevices()->listLowRisk(['page' => 1]);
SaudiFda::medicalDevices()->listLowRisk(['page' => 2, 'limit' => 10]);SFDA Endpoint: GET /v2/dwh-md/Lowrisk/list/{page}?limit=
getLowRiskProduct(?int $lowRiskId = null, ?int $productId = null, ?string $accountNumber = null, ?string $registrationNumber = null, ?string $crNumber = null)
Get a low-risk device by any combination of identifiers. At least one parameter should be provided.
| Parameter | Type | Required | Default | Description |
|---|---|---|---|---|
lowRiskId |
int | No | null | Low Risk ID |
productId |
int | No | null | Product ID |
accountNumber |
string | No | null | Account number |
registrationNumber |
string | No | null | Registration/license number |
crNumber |
string | No | null | Commercial Registration (CR) number |
SaudiFda::medicalDevices()->getLowRiskProduct(lowRiskId: 123);
SaudiFda::medicalDevices()->getLowRiskProduct(registrationNumber: 'LIC-123');
SaudiFda::medicalDevices()->getLowRiskProduct(crNumber: 'CR-456');SFDA Endpoint: GET /v2/dwh-md/Lowrisk/Product?LowRiskID=&productID=&AccountNumber=&RegistrationNumber=&CrNumber=
Search low-risk devices by keyword.
| Parameter | Type | Required | Default | Description |
|---|---|---|---|---|
keyword |
string | Yes | — | Search term |
page |
int | No | 1 | Page number |
SaudiFda::medicalDevices()->searchLowRisk('face mask', 1);SFDA Endpoint: GET /v2/dwh-md/Lowrisk/search/{keyword}/{page}
Paginated list of GHTF devices.
| Parameter | Type | Required | Default | Description |
|---|---|---|---|---|
page |
int | No | 1 | Page number |
limit |
int | No | — | Results per page |
SaudiFda::medicalDevices()->listGHTF(['page' => 1]);SFDA Endpoint: GET /v2/dwh-md/GHTF/list/{page}?limit=
getGHTFProduct(?int $propertiesId = null, ?int $mdId = null, ?string $referenceNumber = null, ?string $accountNumber = null, ?string $deviceNumber = null, ?string $crNumber = null)
Get a GHTF device by any combination of identifiers.
| Parameter | Type | Required | Default | Description |
|---|---|---|---|---|
propertiesId |
int | No | null | Properties ID |
mdId |
int | No | null | MD ID |
referenceNumber |
string | No | null | Reference number |
accountNumber |
string | No | null | Account number |
deviceNumber |
string | No | null | Device/license number |
crNumber |
string | No | null | Commercial Registration number |
SaudiFda::medicalDevices()->getGHTFProduct(propertiesId: 456);
SaudiFda::medicalDevices()->getGHTFProduct(deviceNumber: 'LIC-456');SFDA Endpoint: GET /v2/dwh-md/GHTF/Product?PropertiesId=&MDId=&ReferenceNumber=&AccountNumber=&DeviceNumber=&CrNumber=
Get GHTF device accessory details.
| Parameter | Type | Required | Default | Description |
|---|---|---|---|---|
propertiesId |
int | Yes | — | Properties ID of the accessory |
SaudiFda::medicalDevices()->getGHTFAccessory(11);SFDA Endpoint: GET /v2/dwh-md/GHTF/Accessory/id/{PropertiesId}
Search GHTF devices by keyword.
| Parameter | Type | Required | Default | Description |
|---|---|---|---|---|
keyword |
string | Yes | — | Search term |
page |
int | No | 1 | Page number |
SaudiFda::medicalDevices()->searchGHTF('hospital bed', 1);SFDA Endpoint: GET /v2/dwh-md/GHTF/search/{keyword}/{page}
Paginated list of TFA devices.
| Parameter | Type | Required | Default | Description |
|---|---|---|---|---|
page |
int | No | 1 | Page number |
limit |
int | No | — | Results per page |
SaudiFda::medicalDevices()->listTFA(['page' => 1]);SFDA Endpoint: GET /v2/dwh-md/TFA/list/{page}?limit=
Get TFA device accessory details.
| Parameter | Type | Required | Default | Description |
|---|---|---|---|---|
propertiesId |
int | Yes | — | Properties ID of the accessory |
SaudiFda::medicalDevices()->getTFAAccessory(11);SFDA Endpoint: GET /v2/dwh-md/TFA/Accessory/id/{PropertiesId}
Search TFA devices by keyword.
| Parameter | Type | Required | Default | Description |
|---|---|---|---|---|
keyword |
string | Yes | — | Search term |
page |
int | No | 1 | Page number |
SaudiFda::medicalDevices()->searchTFA('ultrasound', 1);SFDA Endpoint: GET /v2/dwh-md/TFA/search/{keyword}/{page}
The package registers built-in API routes under /api/saudi-fda (configurable via SFDA_ROUTES_PREFIX). All routes resolve the SaudiFdaClient via Laravel's service container.
| Method | Endpoint | Description | Route Name |
|---|---|---|---|
GET |
/api/saudi-fda/status |
Package health check | saudi-fda.status |
POST |
/api/saudi-fda/auth/token |
Get OAuth2 access token | saudi-fda.auth.token |
GET |
/api/saudi-fda/cosmetics |
List cosmetics (query: page, limit, Keyword) |
saudi-fda.cosmetics.list |
GET |
/api/saudi-fda/cosmetics/{id} |
Get cosmetic by product ID | saudi-fda.cosmetics.by-id |
GET |
/api/saudi-fda/cosmetics/number/{cosmeticNumber} |
Get cosmetic by registration number | saudi-fda.cosmetics.by-number |
GET |
/api/saudi-fda/cosmetics/barcode/{barcode} |
Get cosmetic by barcode | saudi-fda.cosmetics.by-barcode |
POST |
/api/saudi-fda/cosmetics/search |
Advanced cosmetics search | saudi-fda.cosmetics.search |
GET |
/api/saudi-fda/drugs |
List drugs (query: page, limit) |
saudi-fda.drugs.list |
GET |
/api/saudi-fda/food |
List food products | saudi-fda.food.list |
GET |
/api/saudi-fda/food/{id} |
Get food by ID | saudi-fda.food.by-id |
POST |
/api/saudi-fda/food/search |
Search food products | saudi-fda.food.search |
GET |
/api/saudi-fda/medical-devices/low-risk |
List Low Risk devices | saudi-fda.medical-devices.low-risk |
GET |
/api/saudi-fda/medical-devices/ghtf |
List GHTF devices | saudi-fda.medical-devices.ghtf |
GET |
/api/saudi-fda/medical-devices/tfa |
List TFA devices | saudi-fda.medical-devices.tfa |
To disable routes, set SFDA_ROUTES_ENABLED=false in your .env.
Every API method throws one of two exception types:
| Exception | When |
|---|---|
AuthenticationException |
Invalid or missing credentials |
SaudiFdaException |
API error (network, rate limit, 4xx/5xx, timeout) |
use Aghfatehi\SaudiFda\Exceptions\SaudiFdaException;
use Aghfatehi\SaudiFda\Exceptions\AuthenticationException;
try {
$products = SaudiFda::cosmetics()->list();
} catch (AuthenticationException $e) {
// Check SFDA_CONSUMER_KEY and SFDA_CONSUMER_SECRET
report($e);
} catch (SaudiFdaException $e) {
// Network error, rate limit, or SFDA server error
report($e);
}Every API request/response can be stored in the sfda_api_logs table for auditing, debugging, and analytics.
Enable database logging in .env:
SFDA_LOG_DATABASE_ENABLED=true
SFDA_LOG_DATABASE_CONNECTION=mysql # optional, defaults to default DB connectionCreate the table:
php artisan vendor:publish --tag=saudi-fda-migrations
php artisan migrateWhat gets logged:
| Column | Type | Description |
|---|---|---|
service |
string | API service name (cosmetics, drugs, food, medical_devices) |
endpoint |
string | API endpoint called |
method |
string | HTTP method (GET) |
http_code |
int | HTTP status code |
request_payload |
json | Request parameters (masked for sensitive data) |
response_payload |
json | API response data (masked for sensitive data) |
error_message |
text | Error message if the request failed |
duration_ms |
float | Request duration in milliseconds |
ip_address |
string | Client IP address |
created_at |
timestamp | When the request was made |
Query logs with Eloquent:
use Aghfatehi\SaudiFda\Models\SaudiFdaApiLog;
// Recent failed requests
$failures = SaudiFdaApiLog::whereNotNull('error_message')
->latest()
->take(10)
->get();
// Slow requests (> 2 seconds)
$slow = SaudiFdaApiLog::where('duration_ms', '>', 2000)
->latest()
->get();
// Requests by service
$cosmeticsLogs = SaudiFdaApiLog::where('service', 'cosmetics')
->whereDate('created_at', today())
->get();Sensitive data masking: When database logging is enabled, the package automatically masks credentials, tokens, and authorization headers in the logged payloads (e.g., Ejmb****).
| Event | Fired When | Payload |
|---|---|---|
ApiRequestSucceeded |
Any API request succeeds | Endpoint + duration |
ApiRequestFailed |
Any API request fails | Endpoint + response data |
# Full health check — config + authentication + API connectivity
php artisan saudi-fda:check
# Test authentication only
php artisan saudi-fda:check --auth
# View current configuration (credentials masked)
php artisan saudi-fda:check --configvendor/bin/phpunitThe package includes PHPUnit tests for:
- Facade resolution
- Singleton service instances
- Configuration checks
- Authentication errors
CI: GitHub Actions runs tests across PHP 8.1–8.4 x Laravel 9–13 (26 matrix combinations).
The repository includes a complete Postman collection: SFDA-API-Postman.json
Features:
- All 24 SFDA API endpoints with response examples
- Pre-request script for automatic OAuth2 token acquisition
- Test scripts that validate responses and handle 401 token expiry
- Uses environment variables for credentials (never hardcoded)
How to use:
- Postman -> Import -> Select
SFDA-API-Postman.json - Click Environment -> Add (or edit an existing environment)
- Add these Environment variables:
SFDA_CONSUMER_KEY= your consumer keySFDA_CONSUMER_SECRET= your consumer secret
- Make your first request — the token is fetched automatically
MIT — Created by AL-AGHBARI Fatehi — FsoftDev.com