glc36 / maxloop-kernel
Package contains common functions that you can use in your microservices
Requires
- php: ^8.2
- illuminate/contracts: ^10.0||^11.0
- maatwebsite/excel: ^3.1
- mateusjunges/laravel-kafka: ^2.5
- predis/predis: ^2.3
- spatie/laravel-health: ^1.40
- spatie/laravel-package-tools: ^1.16
- tymon/jwt-auth: ^2.1
Requires (Dev)
- larastan/larastan: ^2.9
- laravel/pint: ^1.14
- nunomaduro/collision: ^8.1.1||^7.10.0
- orchestra/testbench: ^9.0.0||^8.22.0
- pestphp/pest: ^3.0
- pestphp/pest-plugin-arch: ^3.0
- pestphp/pest-plugin-laravel: ^3.0
- phpstan/extension-installer: ^1.3
- phpstan/phpstan-deprecation-rules: ^1.1
- phpstan/phpstan-phpunit: ^1.3
- phpunit/phpunit: ^11.5
- spatie/laravel-ray: ^1.35
- squizlabs/php_codesniffer: ^3.11
Suggests
None
Provides
None
Conflicts
None
Replaces
None
- dev-main
- v2.17.0
- v2.16.3
- 2.16.2
- v2.16.1
- v2.16.0
- v2.15.1
- v2.15.0
- v2.14.1
- v2.14.0
- v2.13.1
- v2.13.0
- v2.12.2
- v2.12.1
- v2.12.0
- v2.11.0
- v2.10.1
- v2.10.0
- v2.9.0
- v2.8.2
- v2.8.1
- v2.8.0
- v2.7.0
- v2.6.0
- v2.5.3
- v2.5.2
- v2.5.1
- v2.5.0
- v2.4.4
- v2.4.3
- v2.4.2
- v2.4.1
- v2.4.0
- v2.3.1
- v2.3.0
- v2.2.0
- v2.1.0
- v2.1.0-beta.2
- v2.1.0-beta.1
- v2.0.2
- v2.0.1
- v2.0.0
- v1.11.3
- v1.11.2
- v1.11.1
- v1.11.0
- v1.10.2
- v1.10.1
- v1.10.0
- v1.9.0
- v1.8.1
- v1.8.0
- v1.7.1
- v1.7.0
- v1.6.0
- v1.5.0
- v1.4.0
- v1.3.0
- v1.2.0
- v1.1.0
- v1.0.0
- dev-feature/MMS-1512-mms---kernel---pass-expired_at-
- dev-feature/MMS-1394-mms---cas---add-tenant-currency
- dev-feature/MMS-1465-mms---kernel---add-datadog-metr
- dev-staging
- dev-MMS-1363-mms---kernel---update-readme-fi
- dev-feature/ML-34-gsconnector-adjustment
- dev-feature/MMS-478-mms---implement-credential-acces
- dev-temporary-disable-rate-limit
- dev-MMS-355-merchant-providers-management-ba
- dev-bugfix/cache-fixes
- dev-feature/MMS-237-permissions-list-config---maxloo
- dev-MMS-190-handle-auth-context
- dev-feature/MMS-156-mms---curl-library
- dev-feature/MMS-123-application-setup-backend-maxloo
- dev-feature/MMS-121-authorization-middleware---maxlo
- dev-MMS-153-sign-token-with-cache-data---max
- dev-feature/MMS-15-mms---queue-job-helper
- dev-feature/MMS-18-mms---event-and-audit-logging
- dev-feature/MMS-41-mms---include-revoke-jwt-methods-
- dev-feature/MMS-16-mms---log-helper
- dev-MMS-63-mms---enhance-validate-jwt-token-
- dev-MMS-40-mms---include-validate-jwt-with-c
- dev-feature/MMS-13-mms---service-to-service-communic
- dev-feature/MMS-54-mms---permissions-list-config
- dev-feature/MMS-11-mms---middleware-for-jwt-claims-v
- dev-MMS-47-mms---validate-jwt-token-api-maxl
- dev-bugfix/code-styling-fixes
- dev-feature/standardize-code-tools
- dev-feature/MMS-39-mms---jwt-helper
- dev-feature/MMS-10-cache-helper
- dev-feature/MMS-12-mms---centralized-configuration
This package is auto-updated.
Last update: 2026-11-13 05:37:30 UTC
README
Introduction
- This package contains common functions that you can use in your microservices to check authentication, authorization and more.
1. Installation Via Composer
You can install the package via composer:
composer require glc36/maxloop-kernel
2. Installation Locally For Development
Create path following with
<project_root>/packages/maxloop-kernel
Mount the package into your following container
mms-centralize-authenticate-service:
.....
volumes:
.....
- ./packages/maxloop-kernel:/var/www/maxloop-kernel # Mount this to your container
Configure to your composer.json to create a path repository that uses a symlink:
# Switch the tag version to `dev-main`
"glc36/maxloop-kernel": "dev-main",
# Switch the minimum-stability from `stable` to `dev`
"minimum-stability": "dev",
# Add the following to end of composer.json
"repositories": [
{
"type": "path",
"url": "/var/www/maxloop-kernel",
"options": {
"symlink": true
}
}
]
Lastly run the following command to require your package:
composer update glc36/maxloop-kernel
3. Configuration
3.1 Maxloop Kernel
3.1.1 Publish Config Assets
This library includes eight configuration files for managing your application's microservices and permissions:
- microservice.php: Manages the configuration of the installed microservice.
- connector.php: Centralized configuration for all service connectors within your application.
- permissions.php: Defines the permissions specific to the installed microservice.
- api-error-exception.php: Defines the error codes & message for ApiException.
- http-client.php: The http client configuration for HttpClient Utility.
- security.php: Manage the security actions for Security Action Sync.
- notification.php: Configuration for Notification Service (N8n webhooks).
- health-check.php: Configuration for Health Check Circuit Breaker.
php artisan vendor:publish --tag=maxloop-kernel-config
This library also installed Laravel Kafka package for managing Kafka topics and messages. Hence, kindly run the command above to publish the Kafka config file.
php artisan vendor:publish --tag=laravel-kafka-config
3.1.2 Setting up config variables
Include variables below into your project application's .env and can be use as such: config('microservice.name')
MICROSERVICE_NAME=microservice // Name of the microservice
MICROSERVICE_JWT_SIGNATURE=0u5qVEQkmSfhl // HS256 signature
MICROSERVICE_JWT_TTL=900 // JWT expiration time in seconds (default: 15 minutes)
MICROSERVICE_REFRESH_JWT_TTL=172800 // Refresh JWT expiration time in seconds (default: 2 days)
MICROSERVICE_JWT_CACHE_TTL=300 // Cache duration in seconds (default: 5 minutes)
MICROSERVICE_ACCESS_CACHE_TTL=600 // Cache duration in seconds (default: 10 minutes)
MICROSERVICE_SHARED_TOKEN=gjyu5yjy8er // Shared Static Token for Services Internal Communication
3.2 Kafka
3.2.1 Publish Kafka Config Assets
Publish the Kafka configuration file to your config directory.
php artisan vendor:publish --tag=laravel-kafka-config
3.2.2 Setting up Kafka config variables
Include the Kafka environment variables below into your project application's .env
// The kafka broker address (default: localhost:9092)
KAFKA_BROKERS=kafka-service:10000
// The security protocol for Kafka (default: PLAINTEXT)
KAFKA_SECURITY_PROTOCOL=PLAINTEXT
// The consumer group ID for Kafka (default: group)
KAFKA_CONSUMER_GROUP_ID=mms-cas-consumers
// Enable or disable debug mode for Kafka (default: false)
KAFKA_DEBUG=true
// Cache driver (default: file)
KAFKA_CACHE_DRIVER=redis
3.2.3 Publish Service Providers
This library also installed Kafka service provider to bootstrap Kafka core service in your application.:
- KafkaServiceProvider: Register Kafka producer services.
php artisan vendor:publish --tag=maxloop-kernel-provider
After publishing, you can add the service providers to your bootstrap/providers.php file:
<?php
return [
// existing code ...
App\Providers\KafkaServiceProvider::class,
];
3.2.4 Publish Kafka Supervisor Config Assets
This library also installed Kafka supervisor config file to bootstrap Kafka core service in your application.
php artisan vendor:publish --tag=maxloop-kafka-supervisor
3.2.5 Publish KafkaConsumerCommand to your service (Optional)
This libary includes a KafkaConsumerCommand class to consume Kafka messages. Use the command below to publish the command to your application if needed.
php artisan vendor:publish --tag=maxloop-kafka-commands
Kindly refer to Kafka documentation for more information on how to use the command.
3.2.6 Kafka SSL / SASL Configuration
KafkaProducerService applies SSL/SASL settings to the underlying mateusjunges/laravel-kafka builder so the producer connects with the correct security protocol. Without this, the producer connects as PLAINTEXT regardless of .env β TLS-only or SASL-secured brokers will reject the handshake and the publish call surfaces as Flush Returned With Error Code -185: 'Local: Timed Out'.
3.2.6.1 Supported Protocols
KAFKA_SECURITY_PROTOCOL | Behavior |
|---|---|
PLAINTEXT (default) | No security applied. Use for local docker-compose Kafka. |
SSL | TLS encryption only. Broker is verified via CA cert; no client credentials. |
SASL_PLAINTEXT | SASL username/password over a plain TCP connection. |
SASL_SSL | SASL username/password over a TLS connection. Common for managed Kafka (AWS MSK, Confluent Cloud). |
3.2.6.2 Add SSL Block to config/kafka.php
The published config/kafka.php from laravel-kafka does not include an ssl block. Add the following alongside the existing securityProtocol and sasl entries:
'ssl' => [
'ca_location' => env('KAFKA_SSL_CA_LOCATION'),
'certificate_location' => env('KAFKA_SSL_CERTIFICATE_LOCATION'), // mTLS only
'key_location' => env('KAFKA_SSL_KEY_LOCATION'), // mTLS only
'key_password' => env('KAFKA_SSL_KEY_PASSWORD'), // mTLS only
],
3.2.6.3 CA Certificate β Per-Service Responsibility
Each consuming service must place its CA certificate inside the service's own filesystem (the kernel does not ship a default). Recommended location:
microservices/<your-service>/storage/private/certs/kafka/<your-ca>.pem
Then set KAFKA_SSL_CA_LOCATION in the service's .env to the absolute path inside the container, for example:
KAFKA_SSL_CA_LOCATION=/var/www/microservice/html/storage/private/certs/kafka/origin_ca_rsa_root.pem
If securityProtocol is SSL or SASL_SSL and the configured CA path is empty or the file does not exist, KafkaProducerService throws:
RuntimeException: Kafka SSL CA certificate not found at: /path/to/cert.pem
This is intentional β silent fallbacks hide misconfiguration. Fail loudly on the first publish so deploys catch missing certs immediately.
3.2.6.4 Environment Variables
# Protocol selection
KAFKA_SECURITY_PROTOCOL=SSL # PLAINTEXT | SSL | SASL_PLAINTEXT | SASL_SSL
# SASL credentials (required when protocol is SASL_PLAINTEXT or SASL_SSL)
KAFKA_MECHANISMS=PLAIN # PLAIN | SCRAM-SHA-256 | SCRAM-SHA-512
KAFKA_USERNAME=
KAFKA_PASSWORD=
# SSL CA path (required when protocol is SSL or SASL_SSL β RuntimeException thrown if empty or file missing)
KAFKA_SSL_CA_LOCATION=
# mTLS client certificate (only when broker requires client-side cert auth)
KAFKA_SSL_CERTIFICATE_LOCATION=
KAFKA_SSL_KEY_LOCATION=
KAFKA_SSL_KEY_PASSWORD=
3.2.6.5 Per-Environment Setup
| Environment | Typical configuration |
|---|---|
| Local | KAFKA_SECURITY_PROTOCOL=PLAINTEXT (default). No cert or credentials needed. |
| Staging / Prod | KAFKA_SECURITY_PROTOCOL=SSL (or SASL_SSL if the broker requires SASL). Place the CA cert under storage/private/certs/kafka/ in your service and set KAFKA_SSL_CA_LOCATION to the in-container absolute path. |
4. Cache Generator Helper
The CacheGenerator class provides a helper for managing cache operations in our project application. It simplifies common tasks like setting, retrieving, and deleting cache values, with support for microservice-specific cache key prefixes.
4.1 Features
- Automatically prefixes cache keys with a microservice name.
- Supports setting cache with a time-to-live (TTL) or permanently.
- Retrieves cache values.
- Deletes individual cache values.
- Deletes all cache values for a specific microservice.
4.2 Notes
Ensure your project application has the required dependencies, such as Redis for cache storage, and include this helper in your project.
4.3 Configuration
The CacheGenerator class uses the following configurations:
microservice.name: The name of the microservice used as a prefix for cache keys.database.redis.cache.database: Specifies the Redis database used for caching.
Ensure these configurations are defined in your config files.
4.4 Constructor
Creates a new CacheGenerator instance with the given key.
$cache = new CacheGenerator(string $key);
4.5 Set a Cache Value
Sets a value in the cache with an optional TTL (time-to-live).
$cache->set(array|string|null $value, ?int $ttl = null): bool
4.6 Get a Cache Value
Retrieves a value from the cache.
$cache->get(): mixed
Pass a closure as the default value and store it, if the requested cache doesn't exist
$cache->get(function () use ($request) {
// If cache data not found, retrieve data again
return DB::table('users')->get();
}, 100); // Set cache TTLβif not set, it will be store forever.
4.7 Delete a Specific Cache Entry
Deletes the specific cache value associated with the key.
$cache->delete(): bool
4.8 Delete All Cache Entries for a Microservice
Deletes all cache entries for the microservice by scanning for keys with the microservice prefix.
CacheGenerator::deleteAll(): bool
Deleting all cache from a microservice only supported in redis driver
4.8 Cache Atomic Lock
Manage application cache keys and atomic locks, with support for microservice and lock prefix
4.8.1 Basic Usage
$cache = new CacheGenerator('user.profile.123');
$response = $cache->lock(
lockDuration: 10,
callback: function() {
// Critical section
return 'result';
},
);
4.8.2 Advance Usage
$cache = new CacheGenerator('user.profile.123');
try {
$response = $cache->lock(
lockDuration: 10,
callback: function() {
// Critical section
return 'result';
},
maxWaitTime: 5 // Optional wait timeout
lockSuffix: ':lock' // Optional lock suffix
);
return $response ?: ApiResponse::error('Another request is currently processing. Please try again shortly.');
} catch (LockTimeoutException $exception) {
// Optional on using LockTimeoutException if using maxWaitTime
Log::warning('Lock timeout encountered', [
'key' => $cache->getKey(),
'message' => $exception->getMessage()
]);
return ApiResponse::error('Lock acquisition timed out');
}
Example of generated cache lock key will be: cas.user.profile.123:lock
5. ApiResponse Utility Class
The ApiResponse class provides a standardized way to return JSON responses in our project application. It simplifies handling success, error, validation, and paginated responses.
5.1 Method
- success
A method for generating a success response.
public static function success(string $message, array|LengthAwarePaginator $data, int $statusCode = JsonResponse::HTTP_OK): JsonResponse
Parameters:
- string $message: The success message.
- array $data: Additional data to include in the response body (optional).
- int $statusCode: HTTP status code (default is 200).
Example:
return ApiResponse::success('Operation Successfully', ['key' => 'value'], 200)
- error
A method for generating a error response.
public static function error(string $message, array $data = null, int $statusCode = JsonResponse::HTTP_BAD_REQUEST): JsonResponse
Parameters:
- string $message: The error message.
- array $data: Additional error details (optional).
- int $statusCode: HTTP status code (default is 400).
Example:
return ApiResponse::error('An Error Occurred', ['key' => 'value'], 400)
- validationException
Handles and formats validation exceptions.
public static function validationException(ValidationException $exception): JsonResponse
Parameters:
- ValidationException $exception: The validation exception instance.
Example:
return ApiResponse::validationException($exception);
Response Format:
{
"status": false,
"message": "Validation Error: Ensure All Fields Are Correctly Filled",
"body": {
"field": ['field1' => ['The Field1 Is Required']]
}
}
- customException
Handles and formats api error exceptions.
public static function customException(ApiErrorException $exception): JsonResponse
Parameters:
- ApiErrorException $exception: The api error exception instance.
Example:
return ApiResponse::customException($exception);
Response Format:
{
"status": false,
"message": "General Error",
"data": {
"key": "value"
},
"error_code": 1000
}
- exceptionError
Handles and formats general exceptions.
public static function exceptionError(Exception $exception, string $message = null): JsonResponse
Parameters:
- Exception $exception: The general exception instance.
- string $message: The exception error message.
Example:
return ApiResponse::exceptionError($exception);
Response Format:
{
"status": false,
"message": "General Error Occurred",
"body": null
}
6. Custom Exception & Handling
6.1 MaxloopExceptionBaseClass
The MaxloopExceptionBaseClass class that provides a robust exception handling system and standardized API responses for Laravel applications.
6.1.1 Features
- Easily handle exceptions and return standardized error responses.
- Automatically format validation errors in a consistent structure.
- Easily extend and customize the exception handling logic.
- Simplify API responses with success and error response helpers.
6.1.2 Usage
To automatically transform any exception and render as ApiResponse json response, register the handler in your bootstrap/app.php file:
return Application::configure(basePath: dirname(__DIR__))
->withRouting(...)
->withMiddleware(...)
->withExceptions(function (Exceptions $exceptions) {
$exceptions->renderable(function (\Exception $exception, $request) {
if ($request->is('*api*') || $request->wantsJson()) {
$handler = new MaxloopExceptionBaseClass();
return $handler->render($request, $exception);
}
});
})->create();
$request->is(" api ") (Optional to check the path contain "api")
$request->wantsJson() (Optional to check client wants the response as JSON format -> Accept: application/json)
6.1.3 Examples
Handling Validation Errors
When a validation error occurs, the response will look like this
{
"status": false,
"message": "Validation Error: Ensure All Fields Are Correctly Filled",
"data": {
"email": ["The email must be a valid email address."]
}
}
Custom Exception Handling
When a exception error occurs, the response will look like this
{
"status": false,
"message": "General Error Occurred",
"data": null
}
6.2 ApiErrorException
The ApiErrorException class that provide an exception allow passing others parameters.
6.2.1 Features
- Allow to set system error_code & message in config file.
- Allow to pass custom 'errorcode', (not http status code)_.
- Allow to pass custom 'data'.
6.2.3 Set Config File
To define your exception custom error codes & messages in api-error-exception.php.
// e.g.
return [
'messages' => [
'1000' => 'General Error',
# without attributes
'1001' => 'Error Message 1001',
# with attributes
'1002' => 'Error Message 1002 :attribute',
]
];
6.2.4 Usage
use Glc36\MaxloopKernel\Exception\ApiErrorException;
# 1. default error code
throw new ApiErrorException('Testing Api Error Exception Message');
/* response
{
"status": false,
"message": "Testing Api Error Exception Message",
"data": {},
"error_code": 1000
}
*/
# 2. error code + attributes + data
throw new ApiErrorException(
'', // custom message
1002, // error code
['attribute' => 'attribute-value'], // attributes
['data-1' => 'data-1'] // data
);
/* response
{
"status": false,
"message": "Error Message 1002 attribute-value",
"data": {
"data-1": "data-1"
},
"error_code": 1002
}
*/
# 3. custom error code & message
throw new ApiErrorException('Custom Message', 8888);
/* response
{
"status": false,
"message": "Custom Message",
"data": {},
"error_code": 8888
}
*/
7. JWT Handler
7.1 π Overview
JwtHandler is a utility class for managing JWT (JSON Web Tokens) in your application using the tymon/jwt-auth package.
It provides methods for generating, validating, retrieving, and invalidating JWT tokens.
7.2 βοΈ Features
β
Generate JWT tokens with custom claims
β
Validate JWT tokens (including expiration and signature checks)
β
Retrieve all or specific claims from a token
β
Validate user roles and permissions
β
Invalidate a JWT token (Delete from cache)
Prerequisites
You can publish jwt config file with:
php artisan vendor:publish --provider="Tymon\JWTAuth\Providers\LaravelServiceProvider"
The jwt secret key, ttl or etc. can be setup in kernel config file.
7.3 Generating a JWT Token
To generate a token for a user with custom claim
$jwtHandler = new JwtHandler();
$tokenData = $jwtHandler->signToken($user, 'admin', ['role' => 'admin'], $setCache);
7.4 Set jwt token
Set a jwt token
$jwtHandler = new JwtHandler();
$jwtHandler->setToken($token);
7.5 Validating a JWT Token
Validate JWT Token
$jwtHandler->isValidAccessToken()
7.6 Retrieving Claims or Claim from JWT
Retrieve all or specific claims from a jwt token
$claims = $jwtHandler->getClaims(); // Get all claims
$role = $jwtHandler->getClaim('role'); // Get specific claim
7.7 Invalidating a JWT Token
Invalidate a JWT Token and delete from cache
$jwtHandler->invalidateJwtToken();
7.8 Generate Temporary JWT Token
Generates a short-lived βtemporary tokenβ for one-time usage
$jwtHandler = new JwtHandler();
$tempToken = $jwtHandler->generateTemporaryToken(
$user,
authGuard: 'user',
ttl: 5, // Default is 10 minutes
extraClaims: [
'action' => 'security_verificaiton'
]
);
8. Auth Context Handler & Authorization Helper
8.1 π Overview
AuthContextHandler class provides a centralized way to manage Authorization Context in your application using caching.
It allows setting, retrieving, and validating a user's roles and permissions using their UUID.
Additionally, the Authorization class provides a developer-friendly static API to check permissions easily.
8.2 βοΈ Features
β
Store and retrieve user auth context from cache.
β
Cache format: auth_context.admin.{uuid} or auth_context.user.{uuid}
β
Validate if auth context exists or fetch a fresh one
β
Check user authorization using roles or permissions
β
Supports either_one or required_all authorization conditions
β
Works seamlessly with CasConnector and CacheGenerator services
β
Provides Authorization helper for clean static permission checks
Prerequisites
You can set auth context cache in .env:
MICROSERVICE_ACCESS_CACHE_TTL=600
Can refer to microservice config file.
8.3 Set UUID and Auth Guard
Set a user's UUID
$authContextHandler->setUuid('user-uuid-1234');
Set a user's auth guard
$authContextHandler->setAuthGuard('admin'); // admin or user
8.4 Set Auth Context
Set user's auth context in cache
$authContextHandler->setContext([
'roles' => ['admin', 'editor'],
'permissions' => ['manage.users', 'edit.articles'],
]);
8.5 Get Auth Context
Get auth context from cache (Either return data or boolean)
$context = $authContextHandler->getContext();
$hasContext = $authContextHandler->getContext(true); // returns true or false
8.6 Validate Auth Context
Validates and refreshes context if needed
$isValid = $authHandler->validateContext();
8.7 Fetch Auth Context
Fetch auth context from CAS and cache it
$context = $authHandler->fetchContext();
8.8 Authorization Checks
Check if is authorized on user roles or permissions.
// Check if user has ANY of these roles/permissions
$hasAccess = $authHandler->isAuthorized(['admin', 'editor']);
// Check if user has ALL of these roles/permissions
$hasAllAccess = $authHandler->isAuthorized(
['admin', 'publisher'],
AuthContextHandler::CONDITION_REQUIRED_ALL
);
8.9 Authorization Helper (Static API)
For a cleaner developer experience, use the Authorization class:
use Glc36\MaxloopKernel\Support\Authorization;
// Check a single role/permission
if (Authorization::can('manage.users')) {
// access granted
}
// Check if user has any of these role/permissions
if (Authorization::canAny(['owner', 'edit.articles'])) {
// access granted
}
// Check if user has all of these role/permissions
if (Authorization::canAll(['owner', 'manage.users', 'edit.articles'])) {
// access granted
}
9. Middleware
This library contains FIVE middleware classes for your application:
- JwtAuthMiddleware: Validates JWT tokens before processing requests.
- AuthContextMiddleware: Middleware to validate and preload the authenticated user's context into cache based.
- Authorization Middleware: Middleware for handling role/user group and permission based authorization.
- Api Client Auth Middleware: Middleware for validating API requests using HMAC signatures and public keys.
- Internal Api Auth Middleware: Middleware for authorized internal services can access protected routes using a shared token.
9.1 πΉ JwtAuthMiddleware
The JwtAuthMiddleware ensures that a valid JWT token is present in requests.
9.1.1 βοΈ Features
β
Extracts the JWT token from the Authorization header.
β
Validates the token using JwtHandler.
β
Blocks unauthorized access.
9.1.2 π Installation
Register as a Global Middleware in bootstrap/app.php:
use Glc36\MaxloopKernel\Middleware\JwtAuthMiddleware;
->withMiddleware(function (Middleware $middleware) {
$middleware->api(append:[JwtAuthMiddleware::class]);
})
[!IMPORTANT] For CAS microservice change the value
CAS_CONNECTOR_STRATEGYin .env
Can refer toconnector.phpfile By changing to controller the app will call to it own controller's method instead of calling API endpoint
CAS_CONNECTOR_STRATEGY=controller
9.2 πΉ AuthContextMiddleware
The AuthContextMiddleware sets up the user's authorization context for subsequent requests in the application lifecycle.
9.2.1 π Installation
Register as aliases in bootstrap/app.php:
use Glc36\MaxloopKernel\Middleware\AuthContextMiddleware;
->withMiddleware(function (Middleware $middleware) {
$middleware->api(append:[AuthContextMiddleware::class]);
})
9.3 πΉ Authorization Middleware (New Method)
The Authorization middleware restricts access to routes based on user roles/user groups or permissions.
9.3.1 βοΈ Features
β
Supports single role/permission, multiple required roles/permissions (AND condition), and either one of multiple roles/permissions (OR condition).
β
Extracts roles/permissions from the User Token Cache.
β
Uses a custom Authorize Guard for role/permission validation.
β
For user guard (Tenant BO), it will check on user group/permission instead.
9.3.2 π Installation
Register as aliases in bootstrap/app.php:
use Glc36\MaxloopKernel\Middleware\AuthorizationMiddleware;
->withMiddleware(function (Middleware $middleware) {
$middleware->alias([
'authorization' => AuthorizationMiddleware::class
]);
})
9.3.3 Usage
After you have registered the aliases as shown above, you can use them in your Routes and Controllers much the same way you use any other middleware:
// Single role or sing permission
Route::group(['middleware' => ['authorization:admin']], function () { ... });
Route::group(['middleware' => ['authorization:cas.user.users.view']], function () { ... });
// Multiple required roles and permissions (AND condition)
Route::group(['middleware' => ['authorization:admin,cas.user.users.view']], function () { ... });
// Either one of the roles or permissions (OR condition: must have AT LEAST ONE role or ONE permission)
Route::group(['middleware' => ['authorization:admin|cas.user.users.view']], function () { ... });
// Usesage for Tenant Bo (AND condition, scenario: Tenant Profile)
Route::group(['middleware' => ['authorization:owner,cas.user.users.view']], function () { ... });
9.4 πΉ Api Client Auth Middleware (New Method)
The Api Client Auth middleware ensures only trusted clients can access your microservice endpoints.
9.4.1 βοΈ Features
β
Ensure that only signed and timestamped requests can proceed.
β
Prevent replay attacks using timestamp and request ID (requests older than ~5 minutes are rejected).
β
Authenticate requests via public API keys and HMAC SHA256 signatures.
β
Support signing requests that include file uploads by using file metadata (name, size, MIME type, SHA-256 hash).
9.4.2 π Installation
Register as middleware group alias in bootstrap/app.php:
use Glc36\MaxloopKernel\Middleware\ApiClientAuthMiddleware;
->withMiddleware(function (Middleware $middleware) {
$middleware->group('integration-api', [
ApiClientAuthMiddleware::class,
]);
})
9.4.3 Usage
Once registered, you can apply the integration-api middleware group to any route or route group:
Route::middleware(['api', 'integration-api'])
->prefix('api/integration')
->name('api.integration.')
->group(module_path($this->name, '/routes/integration.php'));
9.4.4 π§ͺ Required Headers
Api Clients MUST include the following headers:
| Header | Description | Required |
|---|---|---|
X-Api-Key | Public key issued to the client | β |
X-Signature | HMAC SHA256 of the request data | β |
X-Timestamp | Current epoch time (in seconds) | β |
X-Request-ID | Unique ID per request (UUID) | β |
9.4.5 π X-Signature Generation
Api Client MUST calculate the signature using the same payload that the middleware signs: the JSON body (including file metadata) plus X-Request-ID and X-Timestamp.
On the server side, the body used for signing is prepared by ApiClientAuthMiddleware::prepareRequestDataForSigning():
- All non-file fields are taken from the request input.
- Raw uploaded files are removed from the normal fields.
- A
_filesobject is added when files are uploaded.- For single-file fields, the key is the field name (for example,
file). - For multi-file fields (for example,
photos[]), keys becomephotos.0,photos.1, etc. - Each file entry contains:
name: original filenamesize: file size in bytestype: MIME typehash: SHA-256 hash of the file contents
- For single-file fields, the key is the field name (for example,
In pseudocode, the signature is:
HMAC_SHA256(json_body_with_file_metadata + request_id + timestamp, secret_key)
π§©Example in PHP:
// Example payload to sign, including regular fields and file metadata
// Assume $filePath points to the file you are uploading
$filePath = '/path/to/invoice.pdf';
$data = [
'order_id' => 'ORD-123',
'amount' => 99.90,
// File metadata must match what the server will compute
'_files' => [
'invoice' => [
'name' => basename($filePath),
'size' => filesize($filePath),
'type' => mime_content_type($filePath),
'hash' => hash_file('sha256', $filePath),
],
],
];
// JSON body used for signing
$requestBody = json_encode($data, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
// Unique per request (e.g. UUID string)
$requestId = 'a1f4d7b2-3c9e-4e9f-8d1b-0c2a5c4b7d91';
// Current epoch time in seconds
$timestamp = time();
// Concatenate body + request_id + timestamp
$dataToSign = implode('', [$requestBody, $requestId, $timestamp]);
// Generate the signature with the client's secret key
$signature = hash_hmac('sha256', $dataToSign, $secretKey);
// Attach headers when calling the API
$headers = [
'X-Api-Key' => $publicKey,
'X-Signature' => $signature,
'X-Timestamp' => $timestamp,
'X-Request-ID' => $requestId,
];
9.5 πΉ Internal Api Auth Middleware (New Method)
The Internal Api Auth middleware protecting internal-only endpoints in a microservice architecture where service-to-service communication must be authorized by a shared token.
Prerequisites
You can set shared token key .env:
MICROSERVICE_SHARED_TOKEN=test
Can refer to microservice config file.
9.5.1 π Installation
Register as middleware group alias in bootstrap/app.php:
use Glc36\MaxloopKernel\Middleware\InternalApiAuthMiddleware;
->withMiddleware(function (Middleware $middleware) {
$middleware->group('internal-api', [
InternalApiAuthMiddleware::class
]);
})
9.5.2 Usage
Once registered, you can apply the integration-api middleware group to any route or route group:
Route::middleware(['api', 'internal-api'])
->prefix('api/internal')
->name('api.internal.')
->group(module_path($this->name, '/routes/internal.php'));
9.5.3 π§ͺ Required Headers
Internal Api MUST include the following headers:
| Header | Description | Required |
|---|---|---|
X-Service-Auth | Public key issued to the client | β |
By using
InternalServiceConnectorwill auto appendX-Service-Authin Header
10. Service to Service Connector
Connector is a microservice-specific connector that provides a convenient way to interact with every microservice between your application.
10.1 π Installation
Define the services in your config/connector.php file
// Global Base URL for communication (Thru API Gateway)
'base_url' => env('CONNECTOR_BASE_URL'),
// Default Request Timeout (in seconds)
// This value is used if no per-request timeout is specified
'timeout' => env('CONNECTOR_TIMEOUT', 10),
// Retry Behavior
// Total number of attempts (including the first) and the delay in milliseconds between attempts.
'retry_attempts' => env('CONNECTOR_RETRY_ATTEMPTS', 1),
'retry_delay' => env('CONNECTOR_RETRY_DELAY', 1000),
// Each microservice unique name, can refer to their microservice name
'cas' => [
// The human-readable name of the service. (Only for display purpose)
'name' => 'Centralize Authentication Service',
// Default strategy is API (api or controller)
'strategy' => env('CAS_CONNECTOR_STRATEGY', 'api'),
// Controller route name
'controllers' => [
'validate_access_token' => 'api.public.validate.access-token',
'revoke_token' => 'api.revoke.token',
'revoke_tokens_by_entity' => 'api.revoke.tokens'
]
],
base_url: Global Base URL for communication (Thru API Gateway).
timeout: Default Request Timeout (in seconds)
retry_attempts: Retry attempts via HTTP request
retry_delay: Delay in milliseconds between retry attempts
strategy: The strategy for connect which can be either 'api' (via HTTP request) or 'controller' (direct method call).
controllers: Route names used for direct controller calls
10.1.1 Request Timeout Override (withTimeout)
All connectors support per-request timeout overriding via the withTimeout() method.
$connector = new InternalServiceConnector();
$response = $connector
->withTimeout(3) // timeout in seconds
->callEndpoint('get', '/general/api/internal/v1/currencies');
Default Behavior:
- If
withTimeout()is not used, the connector falls back to:config('connector.timeout')
10.1.2 Request Retry Mechanism (withRetry)
All connectors support per-request retry mechanism via the withRetry() method. This allows automatic retry of failed HTTP requests.
$connector = new InternalServiceConnector();
$response = $connector
->withRetry(5, 2000) // 5 retry attempts, 2000ms delay between retries
->callEndpoint('get', '/general/api/internal/v1/currencies');
Parameters:
$attempts(int): Number of retry attempts$retryMilliseconds(int): Delay between retries in milliseconds (default: 1000)
Default Behavior:
- If
withRetry()is not used, the connector falls back to:config('connector.retry_attempts') // Default: 1 config('connector.retry_delay') // Default: 1000 (milliseconds)
Configuration:
You can configure global retry settings in your .env file:
CONNECTOR_RETRY_ATTEMPTS=3
CONNECTOR_RETRY_DELAY=1000
10.2 πΉ CasConnector
It facilitates communication with the CAS (Central Authentication Service) by providing methods for validating access tokens and auth context.
10.2.1 Validating an Access Token
Validate a JWT token with the CAS service.
$casConnector = new CasConnector();
// Will pass bearer token in header to the endpoint for validation
$response = $casConnector->validateAccessToken();
10.2.2 Retrieve User Auth Context
Retrieve user auth context on sensitive data such as roles, permissions, tenant id and etc.
$casConnector = new CasConnector();
// Will pass bearer token in header to the endpoint
$response = $casConnector->getAuthContext();
10.2.3 Retrieve Api Client Auth Context
Retrieve Api Client auth context on sensitive data such as roles, permissions, uuid, secret key and etc.
$casConnector = new CasConnector();
$response = $casConnector->getApiClient();
10.2.4 Create User Group
Created user group under tenant by system, with optional permission assignment via application.
$casConnector = new CasConnector();
$response = $casConnector->createUserGroup([
'name' => 'Marketing Staff',
'code' => 'marketingstaff',
'description' => 'For marketing staff only', // Optional
'permission_application_code' => 'access-control-application' // Optional
]);
10.3 πΉ InternalServiceConnector
It facilitates communication between services by defining internal endpoint and HTTP method.
10.3.1 Configure Microservice Shared Token
Set shared microservice token in .env.
MICROSERVICE_SHARED_TOKEN=asdvcxvcxv
10.3.2 Call Endpoint
Call to services internal endpoint.
$internalConnector = new \Glc36\MaxloopKernel\Services\InternalServiceConnector();
// Will pass shared token in header to the endpoint for authentication
$response = $internalConnector->callEndpoint('get', '/general/api/internal/v1/currencies');
10.4 πΉ MediaConnector
It facilitates communication with the Media (Media Service) by providing methods for upload, retrieve and delete media.
10.4.1 π Installation
Set filesystem disk that you wished to use in .env.
MEDIA_DISK=local
If uses the local disk, should create a symbolic link from source directory
To create the symbolic link, you may use the storage:link Artisan command
php artisan storage:link
10.4.2 Retrieve, Delete and Upload Media
Retrieve, Delete and Upload a media item to the Media Service.
$mediaConnector = new MediaConnector();
// Will pass shared token in header to the endpoint for authentication
$response = $mediaConnector->uploadMedia([
'file' => file_get_contents($path),
'directory' => 'media', // file location
'thumbnail' => true // boolean either want to create a thumbnail
]);
$response = $mediaConnector->retrieveMedia(1); // Pass media ID
$response = $mediaConnector->deleteMedia(1); // Pass media ID
10.5 πΉ VendorConnector
It facilitates communication with the Vendor Service by providing methods for retrieving and validating vendor items.
10.5.1 π Installation
Define the vendor service in your config/connector.php file:
'vs' => [
'name' => 'Vendor Service',
],
10.5.2 Retrieve Vendor Items
Get all vendor items for dropdown/options with caching support.
$vendorConnector = new VendorConnector();
// Will pass shared token in header to the endpoint for authentication
$response = $vendorConnector->getVendorItems();
10.5.3 Get Vendor Item by UUID
Retrieve a specific vendor item by its UUID.
$vendorConnector = new VendorConnector();
// Will pass shared token in header to the endpoint for authentication
$response = $vendorConnector->getVendorItem('vendor-item-uuid-here');
10.5.4 Validate Vendor Item
Check if a vendor item UUID exists and is active.
$vendorConnector = new VendorConnector();
// Will pass shared token in header to the endpoint for authentication
$isValid = $vendorConnector->validateVendorItem('vendor-item-uuid-here');
// Returns boolean: true if valid and active, false otherwise
10.5.5 Clear Cache
Clear the cached vendor items data.
$vendorConnector = new VendorConnector();
$vendorConnector->clearVendorItemsCache();
10.6 πΉ GsConnector
provides a clean API client wrapper for the General Service (GS) in your microservices architecture.
It includes built-in caching for frequently used resources like currencies, payment methods, and payment platforms.
10.6.1 π Installation
Define the vendor service in your config/connector.php file:
'gs' => [
'name' => 'General Service',
],
10.6.2 βοΈ Features
β
Caching support (currencies, payment methods, payment platforms)
β
Currency validation helper (isValidCurrency)
β
Cache management methods (clearCache, refreshCache)
10.6.3 Get Currencies
Get currencies for dropdown/options with caching support.
$gsConnector = app(GsConnector::class);
$currencies = $gsConnector->getCurrencies(); // Cached for 7 days by default
dd($currencies);
10.6.4 Validate a Currency
Validate a currency code whether it exists and active status
if (!$gsConnector->isValidCurrency('USD')) {
throw new Exception("Invalid or inactive currency.");
}
10.6.5 Clear cache for a specific type
$gsConnector = app(GsConnector::class);
$gsConnector->clearCache('currencies');
10.6.6 Refresh cache (clear + re-fetch)
$gsConnector = app(GsConnector::class);
$gsConnector->refreshCache('currencies');
10.6.7 Supported Cache Types
| Type | Cache Key |
|---|---|
| currencies | gs.currencies.list |
11. Logging with Loki (installation process)
This package supports logging to Grafana Loki. Follow these steps to configure it:
11.1 Configure Loki Channel
Add the following configuration to your microservice's config/logging.php file under the channels array:
'loki' => [
'driver' => 'monolog',
'handler' => \Glc36\MaxloopKernel\Services\LokiHandler::class,
'formatter' => Monolog\Formatter\JsonFormatter::class,
'with' => [
'url' => env('LOKI_ENDPOINT', 'http://loki:3100/loki/api/v1/push'),
'labels' => [
'application' => env('APPLICATION_NAME', 'mms'),
'service' => env('MICROSERVICE_NAME', 'cas'),
],
],
],
11.2 Set the default log channel
Update the .env file:
LOG_CHANNEL=loki // Default log channel
LOKI_ENDPOINT=http://loki:3100/loki/api/v1/push
LogHelper::audit(), LogHelper::userActivity() and LogHelper::security() (and so Loggable and LogUserActivityMiddleware success lines) only write when LOG_CHANNEL=loki. On any other channel they write nothing.
11.3 Clear and cache configuration
php artisan config:clear
php artisan config:cache
11.4 Label Structure
The Loki handler uses a structured label system to organize and query logs efficiently.
11.4.1 Label Format
Every line sent to Loki has these labels (Loki Label Standard):
{
"application": "mms",
"service": "cas",
"label": "user-activity",
"tenant": "tenant-123",
"merchant": "unknown",
"currency": "MYR"
}
application,serviceand the defaultlabelcome from thelokichannel config.tenant:tenantin the log context (from theX-Tenant-IDheader viaAddRequestHeaderToLog); when it is missing, thecurrent_tenant_idrequest attribute set by an auth middleware (API client, internal call, portal user).merchant,currency: from the log context (X-Currencyheader for currency).- A missing or blank
tenant/merchant/currencyis always lowercaseunknown(LokiHandler::UNKNOWN).UNKNOWN,Unknownandnullare normalised tounknown. No label is ever sent blank.
11.4.2 Automatic Label Assignment
label | When |
|---|---|
application | Default (from the channel config) |
user-activity | LogHelper::userActivity(), LogUserActivityMiddleware (2xx), Loggable with $activity |
audit | LogHelper::audit() |
security | LogHelper::security() (11.6.3), the kernel auth middlewares (11.4.5) and incoming 401/403/429 request logs |
system | Incoming/outgoing 5xx request logs, and any line at ERROR level or above that has no label |
Kernel lines put their label on that line's context only; they never call Log::shareContext(['label' => ...]), so a label never carries over to later lines.
11.4.3 Manual Label Assignment
Pass the label on the line:
use Illuminate\Support\Facades\Log;
Log::info('Custom log message', ['label' => 'custom-label']);
Log::shareContext(['label' => ...]) still works, but it applies to every later line in the request; prefer the line context.
11.4.4 Best Practices
β οΈ Important: Do not use unique identifiers or wide-range values (e.g., route names, URLs, UUIDs) as labels. This can severely impact Loki query performance.
Good labels:
application,user-activity,audit,security,system
Bad labels:
route-users.update,url-/api/v1/users/123request-id-abc123,user-id-456
11.4.5 HTTP status β label and level
Request logs written by LogRequestMiddleware (incoming, e.g. webhooks), HttpClient / the LogRequest trait (outgoing calls) and the failed-request line of LogUserActivityMiddleware use Glc36\MaxloopKernel\Support\HttpLogStatus:
| Status | Level | Incoming label | Outgoing label |
|---|---|---|---|
| 1xx / 2xx / 3xx | info | application | application |
| 400, 404, 405, 409, 422 and other 4xx | warning | application | application |
| 401, 403, 429 | warning | security | application (a provider's 401/403 is not a security event for us) |
| 5xx | error | system | system |
| no response (timeout, connection error) | error | β | system |
- A label set by the controller in
log_context(incoming) or bygetAdditionalLogContext()(outgoing) wins. LogUserActivityMiddlewarefailures use the same level, with labelsystemfor 5xx andapplicationotherwise (the security line is written by the auth middleware).ApiErrorException(rendered as 422) is logged once by itsreport()method: onewarningline,label=application, request data masked withLogHelper::sanitize(). Laravel's default report is skipped, and nothing is logged when the exception is caught and never reaches the handler.
Security events written by the kernel auth middlewares (LogHelper::security(), level warning, one line per rejection):
| Middleware | event |
|---|---|
ApiClientAuthMiddleware (401) | auth.api_client.credentials_missing, auth.api_client.security_headers_missing, auth.api_client.request_expired, auth.api_client.invalid_credentials, auth.api_client.invalid_signature (with public_key) |
InternalApiAuthMiddleware (401) | auth.internal.token_missing, auth.internal.invalid_token (the token is never logged) |
AuthorizationMiddleware (403) | auth.permission.denied (with required, condition, guard) |
JwtAuthMiddleware (429) | auth.rate_limited |
JwtAuthMiddleware (401) | auth.jwt.invalid only for a JWT-shaped token that fails its signature or claims check |
Not logged as security events (expected, high volume): portal JWT 401s for a missing, expired, blacklisted, CAS-rejected or malformed (not x.y.z) token, a token for another guard, and AuthContextMiddleware rejections.
Writing the event can never change the response (failures are swallowed). Every rejection an auth middleware has decided on, whether logged or skipped as expected, marks the request with the security_event_handled attribute. LogRequestMiddleware then files its own line for that request under application. So a logged rejection gives exactly one security line, and an expected portal 401 (e.g. an expired JWT) gives none, even on routes that also use LogRequestMiddleware. A 401/403/429 that no kernel auth middleware handled (e.g. a service's own check, or Laravel's throttle) still labels the request line security.
11.5 Middleware
AddRequestHeaderToLog
This package includes the AddRequestHeaderToLog middleware to capture request headers in logs. Register it in your Laravel project's middleware stack in bootstrap/app.php:
use Glc36\MaxloopKernel\Middleware\AddRequestHeaderToLog;
return Application::configure(basePath: dirname(__DIR__))
->withMiddleware(function (Middleware $middleware) {
$middleware->append(AddRequestHeaderToLog::class);
})
->create();
11.6 Logging Helper
The LogHelper class provides methods to log model changes and user activities.
11.6.1 Audit Logging
The audit() method logs model changes (create, update, delete). It only stores affected fields rather than the entire model.
Usage:
use Glc36\MaxloopKernel\Helpers\LogHelper;
LogHelper::audit('User updated', 'update', $user);
11.6.2 User Activity Logging
The userActivity() method writes one line with label=user-activity to the default log channel.
public static function userActivity(
string $message,
array $extra = [],
?Model $target = null, // the model acted on
?string $action = null // create, update, inactivate, delete, ...
): void
Usage:
use Glc36\MaxloopKernel\Helpers\LogHelper;
// Basic usage
LogHelper::userActivity('User logged in successfully');
// With additional context
LogHelper::userActivity('User exported data', [
'file_type' => 'csv',
'record_count' => 1500,
]);
// With a target and an action
LogHelper::userActivity('Ops (ops@a.com) suspended merchant Acme', [], $merchant, 'suspend');
What it fills in:
| Field | Source |
|---|---|
Actor (context.actor, uuid, guard, email, name) | current_jwt_claims request attribute (set by JwtAuthMiddleware), else the bearer token, else system in the console (Kafka, jobs) |
tenant label | $target->tenant_id β current_tenant_id request attribute β JWT tenant_id claim β X-Tenant-ID header β unknown |
extra.action | $action |
extra.target | type, uuid, id from $target (plus anything passed in $extra['target']) |
Use LogHelper::actor() to get the current actor (null when there is no user).
Masking: before writing, values in $extra whose key is sensitive are replaced by ***, the whole value (arrays too), at any depth, whatever the capitals. Default keys (kernel):
api_keysecret_keypasswordtoken
Each service adds its own keys in config/logging.php β sensitive_keys (see 11.6.4), or per request with $request->attributes->set('sensitive_keys', [...]). The same keys are masked on every kernel log line: audit (old_data / new_data), user-activity, and request logs (LogRequest, when masking is on).
LogHelper::sanitize($data, ?array $keys = null, ?callable $mask = null) is the one masking method in the kernel: userActivity(), LogUserActivityMiddleware and the LogRequest trait (HttpClient, LogRequestMiddleware) all use it. LogRequest passes its own keys (getSensitiveKeys()) and format (formatSanitizedValue(), e.g. sk_te***), and its masking is still opt-in.
Difference between audit() and userActivity():
audit(): Tracks model changes (creates, updates, deletes) with old/new data comparisonuserActivity(): Tracks what a person did, in words people can read
11.6.3 Security Logging
The security() method writes one line with label=security, in the same format as userActivity(): same {log_message, metadata} envelope, actor, tenant lookup and masking. Any service can call it.
public static function security(
string $event, // e.g. auth.login.failed, auth.2fa.failed, rbac.role.sync_permissions
string $message, // human-readable
array $extra = [], // reason, email tried, guard, portal, attempts, ... (masked)
?Model $target = null, // the account or object involved, when known
string $level = 'warning'
): void
Usage:
use Glc36\MaxloopKernel\Helpers\LogHelper;
// Failed login (no actor yet)
LogHelper::security('auth.login.failed', "Login failed for {$email}: invalid credentials", [
'email' => $email,
'guard' => 'admin',
'reason' => 'invalid_credentials',
]);
// Lockout on the account
LogHelper::security('auth.account.locked', "Account {$user->email} locked", ['attempts' => 5], $user, 'error');
// RBAC change by the current user
LogHelper::security('rbac.role.sync_permissions', "Permissions synced for role {$role->name}", [
'added' => $added,
'removed' => $removed,
], $role, 'info');
eventis written asextra.event, so you can filter without reading the message:{label="security"} | json | log_message_extra_event="auth.login.failed".warningby default. The label stayssecurityat any level (LokiHandleronly usessystemfor lines without a label), so pass'error'for lockouts and similar.- Name events
<area>.<object>.<what happened>, lowercase, and keep the same names across services.
11.6.4 Optional settings
The defaults live in the kernel. To change them, add this block to the service's existing config/logging.php (top level, next to channels):
// Masked on every kernel log line (audit, user-activity, security, request logs)
'sensitive_keys' => [], // added to the kernel defaults, e.g. ['authorization', 'access_token', 'otp']
'user_activity' => [
'log_system' => false, // Loggable: also log console/Kafka changes as actor=system
],
11.6.5 Application Boot Time Logging
The appBootTime() method log the Process Time from (LARAVEL_START > NOW). It will sending information to Datadog Event.
Usage:
use Glc36\MaxloopKernel\Helpers\LogHelper;
class AppServiceProvider extends ServiceProvider
{
/**
* Bootstrap any application services.
*/
public function boot(): void
{
// providers boot .....
LogHelper::appBootTime(); // log it into end of the boot
}
}
Notice:
- Laravel may have multiple providers boot e.g. (AppServiceProvider, HorizonServiceProvider, KafkaServiceProvider, RateLimitServiceProvider, etc)
- Please Log the boot time in last Provider. Thats the full application boot process time.
11.7 Loggable Trait
To automatically log model events (created, updated, deleted), you can use the Loggable trait in your models:
use Glc36\MaxloopKernel\Traits\Loggable;
class User extends Model
{
use Loggable;
// ... rest of your model code
}
This will automatically log (label=audit, through LogHelper::audit()):
- When a model is created
- When a model is updated (including only changed fields)
- When a model is deleted
11.7.1 User activity (opt-in)
Define $activity on the model to also write one user-activity line per event:
class Merchant extends Model
{
use Loggable;
protected array $activity = [
'target' => 'merchant', // target type
'label' => 'merchant_name', // human-readable attribute
'status' => 'status', // optional status attribute
'status_actions' => [0 => 'inactivate', 1 => 'activate', 2 => 'suspend', 3 => 'terminate'],
];
}
- Action:
createon created,deleteon deleted. On updated: if thestatusattribute changed, its new value is mapped throughstatus_actions(unmapped values giveupdate); otherwiseupdate. - Changes:
extra.changeslists each changed field as{from, to}(withoutupdated_at; enums written as their value). Nothing is written if nothing changed. - Message:
{actor} {action, past tense} {target} {label}, e.g.Tenant Ops (ops@tenant-a.com) inactivated merchant Acme Sdn Bhd. Underscores in the target become spaces (security_policyβsecurity policy); withouttarget, the snake-case class name is used. - System changes (no user: console, Kafka) are skipped unless
logging.user_activity.log_systemistrue.
Models without $activity (or $security, 11.7.2) behave exactly as before.
11.7.2 Security events (opt-in)
For models whose changes are security-relevant (roles, permissions, user groups, security policies, ...), define $security. Each event then writes one label=security line through LogHelper::security(). It works on its own: the model does not need $activity.
class Role extends Model
{
use Loggable;
protected array $security = [
'event' => 'rbac', // event prefix: rbac.role.<action>
'level' => 'notice', // optional, default notice
'target' => 'role',
'label' => 'name',
'status' => 'status',
'status_actions' => [0 => 'inactivate', 1 => 'activate'],
];
}
- Event:
{event}.{target}.{action}, e.g.rbac.role.create,rbac.role.inactivate,rbac.user_group.delete. The target defaults to the snake-case class name. - Keys:
target,label,statusandstatus_actionswork as in$activity(11.7.1). If the model also has$activity, any of these left out of$securityis taken from$activity, so a model with both only needs['event' => 'rbac']. - Same rules as the user-activity line: nothing is written when nothing changed, and system changes are skipped unless
logging.user_activity.log_systemistrue. Withoutevent, nothing is written. - Changes that aren't model events (for example syncing a pivot table) still need a direct
LogHelper::security()call (see 11.6.3).
11.8 Log Format Example
{
"log_message": {
"message": "User updated",
"context": {
"context": 1741358490000,
"tenant_id": "12345",
"entity_id": "1",
"entity_type": "App\\Models\\User"
},
"level": 200,
"level_name": "INFO",
"channel": "loki",
"datetime": "2025-03-07T14:39:17.835317Z",
"extra": {
"event_type": "update",
"old_data": {
"name": "John Doe"
},
"new_data": {
"name": "John Smith"
}
}
},
"metadata": {
"url": "http://127.0.0.1/users/1",
"method": "PATCH",
"user_agent": "Mozilla/5.0",
"ip": "192.168.1.1",
"request_id": "abcd-1234",
"route_name": "users.update"
}
}
11.9 Log Structure
The log structure consists of two main parts:
log_message: Contains the core log information
message: The log messagecontext: Additional context including:context: Unix timestamptenant_id: Tenant identifier from JWT tokenentity_id: ID of the affected modelentity_type: Class name of the affected model
level: The log level (numeric)level_name: The log level name (e.g., "INFO")channel: The logging channel (loki)datetime: ISO timestamp of the log entryextra: Additional data including:event_type: Type of event (create, update, delete)old_data: Previous values of changed fields (for update events)new_data: New values of changed fields
metadata: Contains request-related information
url: The full URL of the requestmethod: HTTP method useduser_agent: Client's user agentip: Client's IP addressrequest_id: Unique request identifierroute_name: Name of the route being accessed
11.10 User Activity Logging
The LogUserActivityMiddleware writes one user-activity line per successful request, through LogHelper::userActivity().
For create / update / delete of a model, prefer
Loggable+$activity(11.7.1): it knows the real action (e.g. inactivate vs update) and the changed fields.
11.10.1 π Overview
This middleware captures user activities with message generation based on HTTP methods and routes. It supports both automatic and custom message formatting using PHP attributes.
11.10.2 βοΈ Features
β
One user-activity line per successful (2xx) request. Failed requests are still logged (prefixed [Failed], with masked request_data and response), but not under label=user-activity: level and label follow the status (4xx warning / application, 5xx error / system, see 11.4.5)
β
Message generation based on HTTP methods
β
Customizable messages using PHP 8.0+ attributes
β
Placeholders (:user, route parameters, request data) replaced in one pass
β
Request data masked (headers and response body are not logged)
β
Request duration tracking
11.10.3 π Installation
Register the middleware in bootstrap/app.php:
use Glc36\MaxloopKernel\Middleware\LogUserActivityMiddleware;
->withMiddleware(function (Middleware $middleware) {
$middleware->alias(['log-user-activity' => LogUserActivityMiddleware::class]);
})
Apply to routes or route groups:
// Apply to specific routes
Route::get('/users', [UserController::class, 'index'])
->middleware(['log-user-activity']);
// Apply to route groups
Route::middleware(['log-user-activity'])->group(function () {
Route::get('/users', [UserController::class, 'index']);
Route::post('/users', [UserController::class, 'store']);
});
11.10.4 Default Message Format
The middleware automatically generates messages with the format: {user} {action} {object}
HTTP Method to Action Mapping (message / extra.action):
GETβviews/viewPOSTβcreates/createPUT/PATCHβedits/updateDELETEβdeletes/delete- Other β
accesses/access
Example:
Admin (admin@example.com) views users.
John Doe (john@example.com) creates product.
11.10.5 Custom Messages with Attributes
Use the UserActivityMessage attribute to customize log messages:
use Glc36\MaxloopKernel\Attributes\UserActivityMessage;
class MerchantController extends Controller
{
#[UserActivityMessage(':user views all merchants')]
public function index()
{
// Logs: "Admin (admin@example.com) views all merchants"
}
// Route parameter + request input
#[UserActivityMessage(':user updates merchant :merchant_name (:merchant)')]
public function update(Request $request, string $merchant)
{
// Logs: "Admin (admin@example.com) updates merchant Acme (mch-uuid-0001)"
}
}
Available Placeholders:
:user- Current authenticated user (name and email):parameterName- Route parameter values (bound models use their key):fieldName- Request input field values
All placeholders are replaced in a single pass and the longest name wins, so :merchant does not break :merchant_name. Only text and number values are inserted, and sensitive input keys are never inserted.
11.10.6 Sensitive Data Sanitization
request_data is masked by LogHelper::userActivity() (see 11.6.2). Add keys per request:
// In your controller method
$request->attributes->set('sensitive_keys', ['pin']);
11.10.7 Sample Log Output
{
"log_message": {
"message": "Admin (admin@example.com) updates merchant Acme (mch-uuid-0001)",
"context": {
"actor": "Admin (admin@example.com)",
"uuid": "user-uuid",
"guard": "admin",
"email": "admin@example.com",
"name": "Admin"
},
"level_name": "INFO",
"extra": {
"method": "PUT",
"status": 200,
"duration_ms": 156.23,
"request_data": { "merchant_name": "Acme", "password": "***" },
"action": "update"
}
},
"metadata": { "url": "...", "method": "PUT", "ip": "192.168.1.1", "request_id": "abcd-1234" }
}
Labels: {application="mms", service="cas", label="user-activity", tenant="<tenant or unknown>"}.
11.11 Notes
- The
audit()method only stores changed fields for update events to minimize log size audit()takes the tenant ID from the JWTtenant_idclaim if present;userActivity()andsecurity()use the target'stenant_id, thecurrent_tenant_idrequest attribute, the JWTtenant_idclaim or theX-Tenant-IDheader- When no tenant is found, the
tenantlabel is lowercaseunknown(all kernel log lines, matchingLokiHandler) - The token should be provided in the Authorization header
- If the token is invalid or missing, tenant_id will be null
- Request metadata is automatically captured through the
AddRequestHeaderToLogmiddleware - All timestamps are in ISO 8601 format with timezone information
- The logger handles special characters and quotes in data fields
- The logger supports models with relationships and custom attributes
12. Queue Job Base Extender
It automatically prefixes the job queue name with the microservice name and adds the microservice name as a job tag for easy identification and monitoring.
12.1 βοΈ Features
β
Automatically appends the microservice name to the queue job name.
β
By default auto set queue job name if not set. Default queue: {microservice}_default
β
Adds the microservice name as a tag for job tracking.
β
Supports easy customization via a config file.
12.2 π Usage
Extends BaseJob in your job file:
use Glc36\MaxloopKernel\Jobs\BaseJob;
class TestJob extends BaseJob
{
public function __construct()
{
parent::__construct();
}
protected function queueName(): string
{
return 'test_job';
}
public function handle()
{
// Your job logic here
\Log::info('Processing data...');
}
}
12.3 Optional: Customize Queue Name
If you need to customize the queue name further, you can override it in your specific job class.
protected function queueName(): string
{
return 'test_job'; // It became 'cas_test_job'
}
12.4 Optional: Customize Tags
If you need to customize the tags further, you can override the tags methods in your specific job class.
public function tags(): array
{
// Additional tags
return array_merge(parent::tags(), ["user:{$userId}"]);
}
13. HttpClient Utility (API Client)
The HttpClient utility provides a standardized way to make HTTP requests in your application. It simplifies common HTTP operations with pre-configured settings and error handling.
13.1 π Overview
HttpClient is a wrapper around Guzzle HTTP client that provides consistent request handling, logging, and error management for your microservices.
13.2 βοΈ Features
β
Pre-configured with sensible defaults for microservice communication
β
Automatic request logging
β
Standardized error handling
β
Support for authentication headers
β
Configurable timeout and retry settings
13.3 Configuration
The HttpClient uses the following configurations from the http-client.php config file:
php artisan vendor:publish --tag=maxloop-kernel-config --force
13.4 Configure your HTTP client settings in the .env file:
HTTP_CLIENT_TIMEOUT=30 # Request timeout in seconds
HTTP_CLIENT_RETRY_TIMES=3 # Number of retry attempts
HTTP_CLIENT_RETRY_SLEEP=1000 # Sleep between retries in milliseconds
HTTP_CLIENT_PROXY=true
HTTP_CLIENT_PROXY_HOST='54.251.100.169'
HTTP_CLIENT_PROXY_PORT='3128'
HTTP_CLIENT_PROXY_TYPE=http
HTTP_CLIENT_PROXY_USERNAME=
HTTP_CLIENT_PROXY_PASSWORD=
13.5 Basic Usage
Create a new HttpClient instance and make requests:
use Glc36\MaxloopKernel\Services\HttpClient;
$httpClient = new HttpClient();
// GET request
$response = $httpClient->get('https://api.example.com/users');
// POST request with JSON data
$response = $httpClient->post('https://api.example.com/users', [
'name' => 'John Doe',
'email' => 'john@example.com'
]);
// PUT request
$response = $httpClient->put('https://api.example.com/users/1', [
'name' => 'Updated Name'
]);
// DELETE request
$response = $httpClient->delete('https://api.example.com/users/1');
13.6 Advanced Usage
13.6.1 Response methods
Reference: https://laravel.com/docs/11.x/http-client#introduction
$response = $httpClient->get('https://api.example.com/users');
$response->body() : string;
$response->json($key = null, $default = null) : mixed;
$response->object() : object;
$response->collect($key = null) : Illuminate\Support\Collection;
$response->resource() : resource;
$response->status() : int;
$response->successful() : bool;
$response->redirect(): bool;
$response->failed() : bool;
$response->clientError() : bool;
$response->header($header) : string;
$response->headers() : array;
13.6.2 Custom Headers
$response = $httpClient->withHeaders([
'X-Custom-Header' => 'Custom Value',
'Accept' => 'application/json',
'Content-Type' => 'application/json'
])->get('https://api.example.com/users');
13.6.3 Authentication
// Bearer token authentication
$response = $httpClient->withToken('your-access-token')
->get('https://api.example.com/protected-resource');
13.6.4 Custom Options
$response = $httpClient->withOptions([
'timeout' => 60,
'connect_timeout' => 20,
'verify' => false // Disable SSL verification (not recommended for production)
])->get('https://api.example.com/users');
13.6.5 Error Handling
try {
$response = $httpClient->get('https://api.example.com/users');
$data = $response->json();
} catch (Illuminate\Http\Client\HttpClientException $e) {
// Handle client errors (4xx)
$statusCode = $e->getStatusCode();
$errorMessage = $e->getMessage();
} catch (\Exception $e) {
// Handle other exceptions
}
14. Api Client Handler
14.1 π Overview
ApiClientHandler is a service class used to manage and validate API client public keys using a caching mechanism.
It helps ensure that an API client is authenticated and cached efficiently to reduce external CAS requests.
14.2 βοΈ Features
β
Store and retrieve api client auth context in cache for performance
β
Validate if auth context exists or fetch a fresh one
β
Check api client authorization using public key
β
Works seamlessly with CasConnector and CacheGenerator services
Prerequisites
You can set auth context cache time to live in .env:
MICROSERVICE_ACCESS_CACHE_TTL=600
Can refer to microservice config file.
14.3 Set Public Key
Set Api Client's Public Key
$apiClientHandler->setPublicKey('bVYP)v%TS69)tNj!');
14.4 Set Auth Context
Set Api Client's auth context in cache
$apiClientHandler->setApiClientCache([
'roles' => ['admin', 'editor'],
'permissions' => ['manage.users', 'edit.articles'],
'uuid' => 'cdc4bc2c-094c-4e2a-bcdf-52a8f1818a19',
'secret_key' => '9er4fW5SF)4(*tI3',
]);
14.5 Get Auth Context
Get auth context from cache (Either return data or boolean)
$context = $apiClientHandler->getApiClientCache();
$hasContext = $apiClientHandler->getApiClientCache(true); // returns true or false
14.6 Validate Auth Context
Validates and refreshes context if needed
$isValid = $apiClientHandler->validateApiClient();
15. BelongsToTenant Trait & TenantScopableInterface
15.1 π Overview
A trait for automatically scoping Eloquent models to a selected tenant based on the current request context.
It supports both direct tenant_id columns and relationship-based multi-tenancy.
15.2 βοΈ Features
β
Applies a global query scope to restrict results by tenant.
β
Supports Neutral Tenant (tenant_id column) and multi-tenant (relationship-based) models.
β
Supports Global Tenant (NULL) β Global rows are always visible to all tenants unless strict mode is enabled.
β
Per-Model Control (is_tenant_strict) β Decide whether a model must have a tenant id or can be global (NULL).
β
Automatically injects the tenant_id on model creation.
β
Provides a convenient scopeWithoutTenant() to bypass tenant scoping.
β
Pluggable via TenantScopableInterface for IDE friendliness and contract enforcement.
15.3 π API Support Matrix
| API Type | Route Prefix | Required |
|---|---|---|
| Tenant API | /api/tenant/* | β |
| Integration API | /api/integration/* | β |
| Internal API | /api/internal/* | β |
| Other API | Other Route | β |
π‘ Why not for internal API?
- Internal APIs are designed for high-performance, system-level communication between microservices. Applying tenant scoping could introduce unnecessary overhead or database constraints, which may degrade performance.
- Internal APIs are used for service-to-service communication within the system and often require full access to tenant-agnostic data (e.g., syncing, global lookups, or batch jobs).
- Tenant scoping in these cases could lead to unexpected query restrictions or missing data, so the global scope is explicitly disabled.
- The service calling it is already fully trusted and the tenant filter is not needed at the model level.
15.4 π Usage
1. Using Direct Tenant Column
use Glc36\MaxloopKernel\Traits\BelongsToTenant;
use Glc36\MaxloopKernel\Contracts\TenantScopableInterface;
class UserGroup extends Model implements TenantScopableInterface
{
use BelongsToTenant;
// This is automatically direct use tenant_id column in table
public static function tenantRelationship(): ?string
{
return null;
}
}
2. If the model uses a custom tenant relationship (e.g., belongsToMany), override tenantRelationship():
use Glc36\MaxloopKernel\Contracts\BelongsToTenant;
use Glc36\MaxloopKernel\Contracts\TenantScopableInterface;
class UserGroup extends Model implements TenantScopableInterface
{
use BelongsToTenant;
public static function tenantRelationship(): ?string
{
return 'tenantUser';
}
}
3. Bypassing Tenant Scope
// Get all records across tenants
Product::withoutTenant()->get();
4. Global (No-Tenant Id) Models
Sometimes, certain models (like system configs, service definitions, fee rules, or provider metadata) should be shared across all tenants and not belong to any specific tenant.
You can make a model βglobalβ by allowing tenant_id = NULL and disabling automatic tenant assignment.
class Evo extends Model
{
use BelongsToTenant;
/**
* When false, tenant id will not be auto-assigned during creation.
* The record may have a NULL tenant_id, meaning it's global.
*/
protected bool $is_tenant_strict = false;
}
Behavior:
- Evo::create(['name' => 'Evo Main']) β tenant_id = NULL (global)
- Evo::create(['name' => 'Evo Sub', 'tenant_id' => 'apexnova']) β tenant_id = 'apexnova'
5. Authorize Tenant Access
The authorizeTenant() method performs manual tenant validation for a specific model instance.
It now fully respects is_tenant_strict rules:
Strict Mode (is_tenant_strict = true or missing):
- Record must have tenant_id === currentTenantId
- Global records (tenant_id = NULL) are rejected
Non-Strict Mode (is_tenant_strict = false):
- Allows:
- tenant_id === currentTenantId
- tenant_id === null (global)
// In your controller
public function update(Request $request, UserGroup $userGroup)
{
// Explicitly validate tenant access before updating
$userGroup->authorizeTenant();
// Continue with update...
$userGroup->update($request->validated());
}
// With custom tenant ID
$userGroup->authorizeTenant('custom-tenant-id');
// In your model (optional override)
class UserGroup extends Model implements TenantScopableInterface
{
use BelongsToTenant;
/**
* Override authorizeTenant method for custom validation logic
*/
public function authorizeTenant(?string $tenantId = null): bool
{
// Custom logic for UserGroup
if ($this->isSpecialGroup()) {
return true; // Skip validation for special groups
}
// Call parent implementation
return parent::authorizeTenant($tenantId);
}
}
Key Features:
- Manual Validation: Call
authorizeTenant()when you need to ensure tenant access - Overridable: Can be customized in individual models for business logic
- Respects Global Scope: Works with existing
BelongsToTenantfunctionality - Performance Optimized: No additional database queries beyond existing tenant scoping
- Strict vs Non-Strict Control:
is_tenant_strictgives per-model control over whether global records are allowed.
16. Permissions Sync to CAS
16.1 π Overview
This package provides an Artisan command to sync permissions, and applications
from your local configuration (config/permissions.php) to the CAS (Central Authentication Service).
16.2 βοΈ Features
β
Sync permissions from config to CAS.
β
Support for microservice shared application.
β
Validates configuration structure before syncing.
β
Logs errors and successes for debugging.
β
Returns proper CLI exit codes (0 for success, 1 for failure).
16.3 π¦ Configuration
Define your permissions, roles, and applications inside config/permissions.php with the following structure: >π‘ You may use a helper class to generate this array dynamically (e.g. Helper::generateApplication())
return [
'permissions' => [
'guards' => [
'admin' => [
[
'code' => 'cas.admin.admins.view',
'name' => 'View Admins',
'description' => 'Permission to view admin list',
'service' => 'cas',
],
// more permissions...
],
],
],
'applications' => [
[
'code' => 'bank',
'name' => 'Bank',
'description' => 'Bank Account System Management',
'has_permissions' => [
'cas.admin.admins.view',
],
'is_service_shared' => true,
'icon': 'mdi mdi-lock'
],
// more applications...
],
];
>β οΈ All sections (permissions.guards, applications) are required and will be validated before syncing.
16.4 π οΈ Sync Command
Run the following Artisan command to sync the permission configuration to CAS
php artisan permissions:sync
β If successful, youβll see:
Permissions successfully synced to CAS.
Under the hood, this command will:
β
Validate the structure of config/permissions.php using PermissionConfigValidator.
β
POST the configuration to your CAS microservice endpoint (/permissions/sync).
β
Output a success or failure message in the console or Log file.
17. HasMedia Trait & HasMediaInterface
17.1 π Overview
A reusable trait + interface to handle media file management (uploading, retrieving, caching, and URL extraction) across multiple models. This integrates with your Media Microservice through MediaConnector and caches results using CacheGenerator.
17.2 βοΈ Features
β
Upload media files (uploadMediaFile) with support for thumbnails and custom directories.
β
Retrieve full media metadata (getMedia) with caching support.
β
Retrieve only the original media URL (getMediaUrl).
β
Retrieve thumbnail URLs (getThumbnailUrl).
β
Configurable per-model media fields and cache prefixes.
β
Automatic cleanup of old media when uploading new ones.
β
Extensible via HasMediaInterface.
17.3 π Usage
1. Implement the Interface
use Glc36\MaxloopKernel\Contracts\HasMediaInterface;
use Glc36\MaxloopKernel\Traits\HasMedia;
class Provider extends Model implements HasMediaInterface
{
use HasMedia;
/**
* Get media configuration mapping.
*
* @return array[]
*/
public function getMediaConfig(): array
{
return [
'logo' => [
'field' => 'logo_media_id', // DB column to store media ID
'cache_prefix' => 'provider-logo-url', // Prefix for cache keys
],
'banner' => [
'field' => 'banner_media_id',
'cache_prefix' => 'provider-banner-url',
],
];
}
/**
* Get the unique identifier used for media cache keys.
*
*
* @return string|int|null Unique identifier (UUID, ID, or null if unavailable).
*/
public function getMediaCacheIdentifier(): string|int|null
{
return $this->uuid;
}
}
>β οΈ Cache keys will follow the format: {cache_prefix}:{identifier}. Example: provider-logo-url:123e4567-e89b-12d3-a456-426614174000
2. Upload Media
$provider = Provider::find(1);
$mediaId = $provider->uploadMediaFile(
'logo', // media type (defined in config)
$request->file('logo'), // file
thumbnail: true,
directory: 'provider/logo',
deleteOld: true
);
$provider->logo_media_id = $mediaId;
$provider->save();
3. Retrieve Media
// Full media response (from cache or media service)
$media = $provider->getMedia('logo');
// Original URL (from cache or media service)
$url = $provider->getMediaUrl('logo');
// Thumbnail URL (from cache or media service)
$thumbUrl = $provider->getThumbnailUrl('logo');
17.4 π Notes
- Use uuid instead of id for cache keys if available (prevents collisions).
- By default, media is cached for 15 minutes (900 seconds).
- Use $forceRefresh = true to bypass cache and force API retrieval.
18. Resolve Client Ip Middleware
18.1 π Overview
A middleware to reliably resolve the real client IP address when running behind multiple layers of proxies (e.g., Cloudflare, AWS ALB/ELB, APISIX, Docker).
By default, Laravel may return the last proxy IP from the X-Forwarded-For chain, which is usually not the real client IP. This middleware corrects that behavior.
18.2 βοΈ Features
β
Supports Cloudflare via the CF-Connecting-IP header.
β
Falls back to the first IP in X-Forwarded-For (the actual client).
β
Validates IP format (both IPv4 and IPv6).
β
Overwrites REMOTE_ADDR so request()->ip() and Request::ip() return the correct client IP.
18.3 π Usage
1. Register the middleware
Add it to your bootstrap/app.php or global middleware stack:
use Glc36\MaxloopKernel\Middleware\ResolveClientIpMiddleware;
->withMiddleware(function ($middleware) {
$middleware->append(ResolveClientIpMiddleware::class);
});
2. Retrieve the client IP
After the middleware runs, you can use Laravelβs built-in helpers:
$ip = request()->ip();
dump($ip);
18.4 π Security Notes
- Always ensure your proxies are trusted in Laravel.
- Only use headers you trust (Cloudflare, AWS ELB, APISIX). Never blindly trust user-supplied headers in an unsecured environment.
- If youβre not behind Cloudflare, you may remove the
CF-Connecting-IPlogic.
18.5 π Example
Before middleware:
dump(request()->ip()); // 172.31.x.x (AWS ALB internal IP)
After middleware:
dump(request()->ip()); // 161.xxx.xxx.x (real client IP)
19. AppContext
19.1 π Overview
AppContext is a simple static context holder for your microservice application.
It allows you to store and retrieve values (like currency or tenant ID) that represent the current execution context (e.g. web request or queued job).
This helps avoid passing the same context values through multiple layers of your application (controllers β services β models β jobs).
>β οΈ Currently only SUPPORT for currency, sooner will support for tenant ID
19.2 βοΈ Features
β
Store the current currency (e.g. USD, MYR, SGD).
β
(Future) Store the current tenant ID for multi-tenant applications.
β
Check if a value is set with simple has*() methods.
β
Clear context between requests or jobs.
19.3 π Usage
Set and get currency
use Glc36\MaxloopKernel\Support\AppContext;
// Set the current currency
AppContext::setCurrency('MYR');
// Retrieve the current currency
$currentCurrency = AppContext::getCurrency(); // "MYR"
// Check if a currency is set
if (AppContext::hasCurrency()) {
// Do something currency-specific
}
Get timezone
use Glc36\MaxloopKernel\Support\AppContext;
// Get the set currency timezone
AppContext::getTimezone();
Clear context
Useful at the end of a request or before processing a new queued job.
AppContext::clear();
20. HasCurrency Trait
20.1 π Overview
The HasCurrency trait provides automatic currency scoping for Eloquent models in your application.
It ensures that queries and records are always filtered and associated with the current execution currency context, which can be set via:
The AppContext::setCurrency() method, or
The X-Currency request header.
This helps keep multi-currency data consistent across web requests, APIs, and jobs.
20.2 βοΈ Features
β
Global Scope β Automatically filters queries by the active currency.
β
Auto-Assign Currency β When creating a new model, the currency_code (or custom field) is automatically set.
β
Supports Global Records (NULL) β Models with no assigned currency are treated as global and appear in all currency contexts.
β
Per-Model Control (is_currency_strict) β Decide whether a model must have a currency or can be global (NULL).
β
Customizable Currency Field β Override the default currency_code column with your own field name.
β
Database Driver Support β Works with both SQL databases and MongoDB collections.
β
Scoped Queries β Includes helper methods to disable scoping or query for specific currencies.
20.3 π Usage
1. Installation
use Glc36\MaxloopKernel\Traits\HasCurrency;
use Illuminate\Database\Eloquent\Model;
class Merchant extends Model
{
use HasCurrency;
// Optionally customize the field name
protected string $currencyField = 'currency';
}
2. Querying Models
// Automatically scoped by current currency
$merchant = Merchant::all();
// Explicitly query for a different currency
$sgdProducts = Merchant::forCurrency('SGD')->get();
// Disable currency scoping
$allProducts = Merchant::withoutCurrency()->get();
20.4 Global (No-Currency) Models
Sometimes, certain models (like accounts, services, or plans) should be available for all currencies or does not have currency assigned.
You can make a model βglobalβ by allowing NULL currency codes.
class Account extends Model
{
use HasCurrency;
/**
* When false, currency will not be auto-assigned during creation.
* The record may have a NULL currency_code, meaning it's global.
*/
protected bool $is_currency_strict = false;
}
Behavior:
- Account::create(['name' => 'Telegram']) β currency_code = NULL (global)
- Account::create(['name' => 'Telegram SGD', 'currency_code' => 'SGD']) β currency_code = 'SGD'
- When queried under any X-Currency context, both specific and global (NULL) accounts will appear.
20.5 Customizing Currency Field
By default, the column currency_code is used.
You can override this in two ways:
class Merchant extends Model
{
use HasCurrency;
// Option 1: Define a property
protected string $currencyField = 'currency';
// Option 2: Define a method
public function getCurrencyFieldName(): string
{
return 'currency';
}
}
β οΈ 20.6 Notes
β Automatic Currency Context
You usually do not need to manually callAppContext::setCurrency().When the
AuthContextMiddlewareis registered, it will automatically:- Validate the JWT token and extract claims
- Resolve the current tenant from the
X-Tenant-IDheader - Set the active currency from the
X-Currencyheader into theAppContext
This ensures the
HasCurrencytrait always has the correct context during API requests.
Example middleware registration:
// bootstrap/app.php
$middleware->group('tenant-api', [
JwtAuthMiddleware::class . ':user',
AuthContextMiddleware::class
]);
21. Security Actions Sync
21.1 π Overview
Each microservice to define its security actions in a config file and sync them to the Centralize Authentication Service (CAS).
21.2 βοΈ Configuration
Each microservice defines its own security actions inside the security.php config file.
// config/security.php
return [
'actions' => [
[
'code' => 'auth.login',
'name' => 'Login',
'application_code' => null, // Null will be regardless of application
'description' => 'Allow users to log in to the system',
'module' => 'auth',
],
[
'code' => 'deposit.approve',
'name' => 'Approve Deposit',
'application_code' => 'payment-application',
'description' => 'Allow approval of deposit transactions',
'module' => 'deposit',
],
],
];
21.3 π Run Command
php artisan security:sync
21.4 π Enforcing Security Actions via Middleware
After syncing security actions to the CAS, each microservice must enforce these actions whenever a protected endpoint is accessed.
This is handled automatically through the packageβs CheckSecurityMiddleware.
21.4.1 π Register the Middleware Alias
Before using the middleware in your routes, register it inside your microserviceβs bootstrap/app.php:
// bootstrap/app.php
use Glc36\MaxloopKernel\Middleware\CheckSecurityMiddleware;
->withMiddleware(function (Middleware $middleware) {
$middleware->alias([
'check.security' => CheckSecurityMiddleware::class,
]);
})
21.4.2 π§ Usage (Route Example)
Route::post('/deposit/approve', [DepositController::class, 'approve'])
->middleware('check.security:deposit.approve');
22. Request Logging
22.1 π Overview
Provides three complementary tools for HTTP request/response logging:
- HttpClient: Built-in logging for outgoing HTTP requests with
withLog()andwithoutLog()methods - LogRequest Trait: For logging outgoing HTTP requests in custom service classes
- LogRequestMiddleware: For logging incoming HTTP requests (webhooks, integration endpoints)
22.2 βοΈ Features
β
Automatic request/response logging with complete context
β
Sensitive data sanitization (api_key, secret_key, password, token)
β
Request duration tracking in milliseconds
β
Success/failure detection with appropriate log levels
β
Customizable log messages and context
β
IP address tracking for incoming requests
β
Request headers logging
β
Automatic security label assignment for error responses (HTTP 400, 403, 429)
22.3 πΉ HttpClient (Recommended)
The HttpClient service has built-in logging enabled by default. You can customize or disable it as needed.
22.3.1 Basic Usage (Logging Enabled by Default)
use Glc36\MaxloopKernel\Services\HttpClient;
$client = new HttpClient('https://api.example.com');
// Automatic logging enabled
$response = $client->post('/payments', ['amount' => 100]);
22.3.2 Disable Logging
// Disable logging for specific request
$response = $client->withoutLog()->get('/health-check');
22.3.3 Customize Logging
// Customize log message, context, and sanitization
$response = $client->withLog(
message: 'Custom payment request',
extraContext: ['provider' => 'Stripe', 'transaction_id' => '123'],
enableSanitization: true,
sensitiveKeys: ['api_key', 'secret_key', 'card_number'],
sanitizedValue: '***REDACTED***'
)->post('/payments', $data);
Parameters:
message: Custom log message (default:[microservice.name] url)extraContext: Additional context to include in logsenableSanitization: Enable data sanitization (default: false)sensitiveKeys: Array of keys to sanitize (default:['api_key', 'secret_key', 'password', 'token'])sanitizedValue: Custom sanitized value format (default: first 5 chars +***)
22.4 πΉ LogRequest Trait
Use in custom service classes that make HTTP requests to external APIs.
22.4.1 Basic Usage
use Glc36\MaxloopKernel\Traits\LogRequest;
use Illuminate\Http\Client\Response;
use Illuminate\Support\Facades\Http;
class PaymentProviderService
{
use LogRequest;
public function createPayment(array $data): array
{
$startTime = microtime(true);
$method = 'POST';
$url = 'https://api.provider.com/payments';
$headers = ['Authorization' => 'Bearer token'];
try {
$response = Http::withHeaders($headers)->post($url, $data);
$durationMs = round((microtime(true) - $startTime) * 1000, 2);
$this->logHttpRequest(
method: $method,
url: $url,
headers: $headers,
requestData: $data,
ipAddress: null,
response: $response,
durationMs: $durationMs
);
return $response->json();
} catch (\Exception $e) {
$durationMs = round((microtime(true) - $startTime) * 1000, 2);
$this->logHttpRequest(
method: $method,
url: $url,
headers: $headers,
requestData: $data,
ipAddress: null,
response: null,
durationMs: $durationMs,
exception: $e
);
throw $e;
}
}
}
22.4.2 Customization
Override methods for custom formatting:
class PaymentProviderService
{
use LogRequest;
// Custom log message format (default: [microservice.name] url)
protected function formatLogMessage(string $url, array $extra = []): string
{
$provider = $extra['provider'] ?? 'Unknown';
return "[Payment-$provider] $url";
}
// Add custom context data
protected function getAdditionalLogContext(array $extra = []): array
{
return [
'provider' => $extra['provider'] ?? null,
'transaction_id' => $extra['transaction_id'] ?? null,
];
}
// Custom sensitive data format
protected function formatSanitizedValue(string $value): string
{
return '***' . substr($value, -4); // Show last 4 chars: ***1234
}
// Custom sensitive keys
protected function getSensitiveKeys(): array
{
return ['api_key', 'secret_key', 'password', 'token', 'card_number', 'cvv'];
}
}
22.5 πΉ LogRequestMiddleware
Automatically logs incoming HTTP requests (webhooks, integration endpoints).
22.5.1 Installation
Register in bootstrap/app.php:
use Glc36\MaxloopKernel\Middleware\LogRequestMiddleware;
->withMiddleware(function (Middleware $middleware) {
$middleware->alias(['log-request' => LogRequestMiddleware::class]);
})
22.5.2 Usage
// Apply to routes
Route::post('/webhook/payment', [WebhookController::class, 'handle'])
->middleware(['log-request']);
22.5.3 Enable Sanitization from Controller
// Enable sanitization for this request
$request->attributes->set('enable_sanitization', true);
// Optional: Customize sensitive keys
$request->attributes->set('sensitive_keys', ['api_key', 'secret_key', 'signature']);
// Optional: Customize log message and context
$request->attributes->set('log_message', 'Payment webhook received');
$request->attributes->set('log_context', ['provider' => 'X Provider']);
// Process webhook...
return response()->json(['status' => 'success']);
22.5.4 Automatic Security Label Assignment
The middleware automatically sets the security label for logs when the response status code indicates a security-related error:
Status Codes:
400- Bad Request403- Forbidden429- Too Many Requests
This helps categorize and query security-related logs in Loki. The label is set automatically via Log::shareContext(['label' => 'security']).
Example:
// When a request returns 403 Forbidden
return response()->json(['error' => 'Access denied'], 403);
// The log will automatically have label = 'security'
// Query in Loki: {label="security", service="your-service"}
22.6 π Sanitization Configuration
Default behavior: Sanitization is disabled by default (data logged as-is).
For HttpClient
Enable sanitization when making requests:
$client = new HttpClient('https://api.example.com');
$response = $client->withLog(
enableSanitization: true,
sensitiveKeys: ['api_key', 'secret_key', 'card_number']
)->post('/payments', $data);
For LogRequest Trait (Service Classes)
Enable sanitization in constructor:
class PaymentProviderService
{
use LogRequest;
public function __construct()
{
$this->enableSanitization(); // Enable globally for this service
}
// Optional: Customize sensitive keys
protected function getSensitiveKeys(): array
{
return ['api_key', 'secret_key', 'password', 'token', 'credit_card', 'cvv'];
}
}
For LogRequestMiddleware (Controller)
Control via request attributes per request:
// Enable sanitization for this request only
$request->attributes->set('enable_sanitization', true);
// Optional: Custom sensitive keys for this request
$request->attributes->set('sensitive_keys', ['api_key', 'signature', 'merchant_key']);
22.7 π Log Output
Log Structure
The log context includes the following fields (in order):
url: Request URLheaders: Request headers (sanitized if enabled)request_data: Request payload/query parameters (sanitized if enabled)ip_address: IP address (for incoming requests only)response: Response body (sanitized if enabled)method: HTTP method (GET, POST, etc.)status: HTTP status codesuccess: Boolean indicating success (2xx status)error: Error details (if exception occurred)message: Exception messagetrace: Filtered stack trace
duration_ms: Request duration in milliseconds
Log Levels
infofor successful requests (2xx status)errorfor failed requests
Log Message Format
- Default:
[microservice-name] url - Example:
[PAS] https://api.example.com/payments
Example Logs
Successful request (without sanitization):
{
"message": "[PAS] https://api.example.com/payments",
"context": {
"url": "https://api.example.com/payments",
"headers": {
"Authorization": "Bearer token123",
"Content-Type": "application/json"
},
"request_data": {
"amount": 100,
"api_key": "sk_test_abc123"
},
"response": {
"id": "pay_123",
"status": "success"
},
"method": "POST",
"status": 200,
"success": true,
"duration_ms": 245.67
}
}
Successful request (with sanitization enabled):
{
"message": "[PAS] https://api.example.com/payments",
"context": {
"url": "https://api.example.com/payments",
"headers": {
"Authorization": "Bearer token123",
"Content-Type": "application/json"
},
"request_data": {
"amount": 100,
"api_key": "sk_te***"
},
"response": {
"id": "pay_123",
"status": "success"
},
"method": "POST",
"status": 200,
"success": true,
"duration_ms": 245.67
}
}
Failed request with error:
{
"message": "[PAS] https://api.example.com/payments",
"context": {
"url": "https://api.example.com/payments",
"headers": {
"Authorization": "Bearer token123"
},
"request_data": {
"amount": 100
},
"response": {
"error": "Invalid API key"
},
"method": "POST",
"status": 401,
"success": false,
"error": {
"message": "HTTP Client Error: Invalid API key",
"trace": [
"/app/Services/PaymentService.php:45 PaymentService::createPayment()",
"/app/Controllers/PaymentController.php:20 PaymentController::store()"
]
},
"duration_ms": 123.45
}
}
Incoming webhook request (with IP address):
{
"message": "[PAS] https://myapp.com/webhook/payment",
"context": {
"url": "https://myapp.com/webhook/payment",
"headers": {
"X-Signature": "sha256=..."
},
"request_data": {
"event": "payment.success",
"payment_id": "pay_123"
},
"ip_address": "203.0.113.42",
"response": {
"status": "received"
},
"method": "POST",
"status": 200,
"success": true,
"duration_ms": 50.23
}
}
23. Notification Service
23.1 π Overview
The Notification Service provides a standardized, scalable way to send notifications through multiple channels (N8n, Pusher, etc.). It uses a factory pattern that makes it easy to add new notification providers while maintaining a consistent API.
23.2 βοΈ Features
β
Multi-channel support (N8n, Pusher, and more)
β
Factory pattern for easy channel selection
β
Fluent message builder API
β
Laravel notification system integration
β
Simple facade for quick access
β
Automatic message object to array conversion
β
Service instance caching for performance
23.3 π¦ Configuration
23.3.1 Publish Config File
The notification config file is automatically included when you publish the package config:
php artisan vendor:publish --tag=maxloop-kernel-config
23.3.2 Environment Variables
Add the following variables to your .env file:
# N8n Notification Service Configuration
N8N_NOTIFICATION_BASE_URL=https://your-n8n-instance.com
N8N_NOTIFICATION_TOKEN=your-n8n-webhook-token
23.4 π Usage
23.4.1 Using the Notify Facade
The simplest way to send notifications is using the Notify facade with the init() method:
use Glc36\MaxloopKernel\Services\Notification\Facades\Notify;
use Glc36\MaxloopKernel\Services\Notification\Messages\N8n\N8nEmailOtpMessage;
// Send email OTP using string channel name
$message = (new N8nEmailOtpMessage())
->email('user@example.com')
->otp('123456')
->expiresIn(5);
$response = Notify::init('n8n')->send($message);
if ($response->successful()) {
// Handle success
}
// Or using enum for type safety
use Glc36\MaxloopKernel\Services\Notification\Enums\NotificationChannel;
$response = Notify::init(NotificationChannel::N8N)->send($message);
23.4.2 Using Laravel Notification System
Create a notification class that implements the toN8n() method:
use Glc36\MaxloopKernel\Services\Notification\Messages\N8n\N8nEmailOtpMessage;
use Illuminate\Notifications\Notification;
class EmailOtpNotification extends Notification
{
protected string $otp;
protected int $expiresInMinutes;
public function __construct(string $otp, int $expiresInMinutes = 5)
{
$this->otp = $otp;
$this->expiresInMinutes = $expiresInMinutes;
}
public function via($notifiable): array
{
return ['n8n'];
}
public function toN8n($notifiable): array
{
return (new N8nEmailOtpMessage())
->email($notifiable->email)
->otp($this->otp)
->expiresIn($this->expiresInMinutes);
}
}
// Usage:
$user->notify(new EmailOtpNotification('123456', 5));
23.5 π§ Message Types
23.5.1 Email OTP Message
Send OTP codes via email:
use Glc36\MaxloopKernel\Services\Notification\Messages\N8n\N8nEmailOtpMessage;
$message = (new N8nEmailOtpMessage())
->email('user@example.com')
->otp('123456')
->expiresIn(5);
Notify::init('n8n')->send($message);
23.5.2 Reset Password Message
Send password reset links via email:
use Glc36\MaxloopKernel\Services\Notification\Messages\N8n\N8nResetPasswordMessage;
$resetUrl = 'https://yourapp.com/reset-password?token=abc123&email=user@example.com';
$message = (new N8nResetPasswordMessage())
->email('user@example.com')
->url($resetUrl)
->expiresIn(5);
Notify::init('n8n')->send($message);
23.5.3 Phone OTP Message
Send OTP codes via SMS/Phone:
use Glc36\MaxloopKernel\Services\Notification\Messages\N8n\N8nPhoneOtpMessage;
$message = (new N8nPhoneOtpMessage())
->phone('+60123456789')
->otp('123456')
->expiresIn(5)
->type('request-otp'); // Optional: custom type
Notify::init('n8n')->send($message);
23.5.4 Slack Message
Send alerts to Slack channels:
use Glc36\MaxloopKernel\Services\Notification\Messages\N8n\N8nSlackMessage;
$message = (new N8nSlackMessage())
->title('System Alert')
->message('Something important happened')
->level('warning') // good, warning, or danger
->type('system-alert'); // Optional: custom type
Notify::init('n8n')->send($message);
23.6 π§ Advanced Usage
23.6.1 Dependency Injection
You can also inject the NotificationService directly:
use Glc36\MaxloopKernel\Services\Notification\Messages\N8n\N8nEmailOtpMessage;
use Glc36\MaxloopKernel\Services\Notification\NotificationService;
class UserController extends Controller
{
public function __construct(
protected NotificationService $notificationService
) {}
public function sendOtp(User $user)
{
$message = (new N8nEmailOtpMessage())
->email($user->email)
->otp('123456')
->expiresIn(5);
$this->notificationService->init('n8n')->send($message);
}
}
23.6.2 Custom Message Types
You can also send raw array payloads if needed:
$payload = [
'channel' => 'email',
'email' => 'user@example.com',
'type' => 'custom-type',
'custom_field' => 'custom_value',
];
Notify::init('n8n')->send($payload);
23.7 π Notes
- The
send()method returns the response from the notification service (varies by channel) - Message objects are automatically converted to arrays before sending
- Service instances are cached for performance (singleton per channel)
- The N8n channel is automatically registered when the package is loaded
23.8 π Adding New Notification Channels
The notification system is designed to be easily extensible. Here's how to add a new channel (using Pusher as an example):
23.8.1 Step 1: Add Channel to Enum
Update NotificationChannel enum:
// packages/glc36/maxloop-kernel/src/Services/Notification/Enums/NotificationChannel.php
enum NotificationChannel: string
{
case N8N = 'n8n';
case PUSHER = 'pusher'; // Add new channel
public function serviceClass(): string
{
return match($this) {
self::N8N => N8n::class,
self::PUSHER => Pusher::class, // Map to service class
};
}
}
23.8.2 Step 2: Create Service Class
Create the service class implementing NotificationServiceInterface:
// packages/glc36/maxloop-kernel/src/Services/Notification/Pusher.php
namespace Glc36\MaxloopKernel\Services\Notification;
use Glc36\MaxloopKernel\Services\Notification\Contracts\NotificationServiceInterface;
use Pusher\Pusher;
use Illuminate\Support\Facades\Log;
class Pusher implements NotificationServiceInterface
{
protected ?Pusher $pusher = null;
protected function getPusher(): Pusher
{
if ($this->pusher === null) {
$this->pusher = new Pusher(
config('notification.channels.pusher.key'),
config('notification.channels.pusher.secret'),
config('notification.channels.pusher.app_id'),
[
'cluster' => config('notification.channels.pusher.cluster'),
'encrypted' => config('notification.channels.pusher.encrypted', true),
]
);
}
return $this->pusher;
}
public function send(mixed $message): mixed
{
$data = is_array($message) ? $message : (array) $message;
$channel = $data['channel'] ?? 'notifications';
$event = $data['event'] ?? 'notification';
$payload = $data['data'] ?? $data;
try {
$result = $this->getPusher()->trigger($channel, $event, $payload);
return $result;
} catch (\Exception $e) {
Log::error('Failed to send Pusher notification', [
'channel' => $channel,
'event' => $event,
'error' => $e->getMessage(),
]);
throw $e;
}
}
}
23.8.3 Step 3: Add Configuration
Update config/notification.php:
'channels' => [
'n8n' => [
'base_url' => env('N8N_NOTIFICATION_BASE_URL'),
'token' => env('N8N_NOTIFICATION_TOKEN'),
],
'pusher' => [
'app_id' => env('PUSHER_APP_ID'),
'key' => env('PUSHER_APP_KEY'),
'secret' => env('PUSHER_APP_SECRET'),
'cluster' => env('PUSHER_APP_CLUSTER'),
'encrypted' => env('PUSHER_ENCRYPTED', true),
],
],
23.8.4 Step 4: Usage
Now you can use the new channel:
// Using string
Notify::init('pusher')->send([
'channel' => 'private-user.123',
'event' => 'notification',
'data' => ['message' => 'Hello!']
]);
// Using enum
use Glc36\MaxloopKernel\Services\Notification\Enums\NotificationChannel;
Notify::init(NotificationChannel::PUSHER)->send([
'channel' => 'private-user.123',
'event' => 'notification',
'data' => ['message' => 'Hello!']
]);
That's it! The new channel is now integrated and ready to use.
24 π Laravel Excel Integration
This document describes the usage of BaseExcelExport and BaseExcelImport from Glc36\MaxloopKernel\Excel
Official package: Laravel Excel
π§ Required PHP Extensions
php_zipphp_xmlphp_gd2php_iconvphp_simplexmlphp_xmlreaderphp_zlib
24.1 BaseExcelExport
BaseExcelExport is an abstract base class for Laravel Excel exports, providing:
- Queued exports (
ShouldQueue) for large datasets - Chunked reading to prevent memory exhaustion
- Text column enforcement (IDs, codes)
- Conditional formatting for numeric columns (green for positive, red for negative)
- Auto-sizing columns
- Flexible export formats (XLSX, CSV, etc.)
24.2 Usage Example
namespace App\Exports;
use Glc36\MaxloopKernel\Excel\BaseExcelExport;
use App\Models\User;
class UsersExport extends BaseExcelExport
{
protected array $textColumns = ['D'];
protected string $fileName = 'users_export.csv';
public function query()
{
return User::query()->select('id', 'name', 'email', 'account_no');
}
public function map($row): array
{
return [
$row->id,
$row->name,
$row->email,
$row->account_no,
];
}
public function headings(): array
{
return ['ID', 'Name', 'Email', 'Account No'];
}
}
24.3 Exporting
- Download immediately:
return (new UsersExport())->download(); // download(?string $fileName = null): \Symfony\Component\HttpFoundation\BinaryFileResponse
- Queue for asynchronous export:
(new UsersExport())->queue(); // (?string $fileName = null, ?string $exportQueuePath = null, ?string $disk = null)
24.4 BaseExcelExport Properties
$fileName β Default file name for export.
Default:'export_YYYY_MM_DD_HH_MM_SS.xlsx'$shouldQueue β Enable queued exports.
Default:true$exportQueuePath β Temporary path for queued exports.
Default:'/exports'$textColumns β Columns to force as text (e.g., IDs, codes).
Default:[]$conditionalFormattingColumns β Columns to apply conditional formatting.
Default:[]$chunkSize β Number of rows to process per chunk.
Default:1000$memoryLimit β PHP memory limit.
Default:'1024M'$timeLimit β Maximum execution time in seconds.
Default:0$writerType β Excel writer type (XLSX, CSV, etc.).
Default:XLSX$timeout β The number of seconds the job can run before timing out (for queue).
Default:120
24.5 BaseExcelImport
BaseExcelImport is an abstract base class for Laravel Excel imports, providing:
- Chunked reading for large files
- Batch inserts for performance
- Queued imports
- Custom value binding for text columns
- Start row and heading row configuration
24.6 Usage Example
namespace App\Imports;
use App\Models\User;
use Glc36\MaxloopKernel\Excel\BaseExcelImport;
class UsersImport extends BaseExcelImport
{
public function model(array $row)
{
return new User([
'id' => $row['id'],
'name' => $row['name'],
'email' => $row['email'],
'account_no' => $row['account_no'],
]);
}
// Validation
public function rules(): array
{
return [];
}
}
24.7 Importing
(new UserImport)->import('users.xlsx');
$array = (new UserImport)->toArray('users.xlsx'); // To Array
$collection = (new UserImport)->toCollection('users.xlsx'); // To Collection
24.8 BaseExcelImport Properties
$chunkSize β Number of rows to read per chunk to prevent memory overload.
Default:1000$batchSize β Number of rows to insert per batch into the database.
Default:500$timeLimit β PHP execution time limit for the import (0 = unlimited).
Default:0$textColumns β Columns to treat as text to prevent automatic type conversion.
Default:[]$startRow β Row number to start reading data from (default is 2 to skip header).
Default:2$headingRow β Row number that contains the headings (default is 1).
Default:1$timeout β The number of seconds the job can run before timing out (for queue).
Default:120
24.9 π Notes
BaseExcelExportandBaseExcelImportmust be extended to be used.- Conditional formatting is applied automatically after sheet generation (exports).
- Text columns prevent Excel from auto-formatting numeric strings.
25 π¦ ZIP Export & Extraction
25.1 Overview
The ZIP feature allows you to:
- Create ZIP archives from in-memory content or physical files
- Add single or multiple files at once
- Use random or fixed ZIP filenames
- Store ZIP files locally on any Laravel storage disk (local, s3, etc.)
- Extract ZIP files to a specified folder with automatic directory creation
- Automatically handle file naming, cleanup, and optional auto-cleanup of extracted files
- Optionally upload ZIP files or extracted files to a Media microservice
25.2 Supported File Inputs
Files added to a ZIP can be:
In-memory content
- Example: JSON, CSV, text strings
- Added using a filename key in the
$filesarray or viaaddFile()
Physical files
- Added using the file path as the value in the
$filesarray or viaaddFile() - The key is used as the filename in the ZIP; if missing or empty, the fileβs basename is used
- Added using the file path as the value in the
Example $files array format:
$files = [
'users.csv' => storage_path('app/exports/users.csv'), // Physical file with custom ZIP name
storage_path('app/exports/orders.csv'), // Physical file, basename used
'log.txt' => 'Exported at ' . now(), // In-memory content
'' => 'Temporary content', // Random filename generated
];
25.3 Adding Files (Single or Multiple)
use Glc36\MaxloopKernel\Services\ZipArchiveService;
$zip = new ZipArchiveService();
// Add single file (content or path)
$zip->addFile(storage_path('app/exports/users.csv'), 'users.csv');
$zip->addFile('Some in-memory content', 'log.txt');
// Add multiple files at once
$zip->addFiles($files);
25.4 Storing ZIP
Random filename:
$zip->store('exports', 'local'); // Saves as 'exports/<random>.zip' on the diskFixed filename:
$zip->storeAs('report.zip', 'exports', 'local'); // Saves as 'exports/report.zip' on the diskCreate & Upload to Media microservice:
$mediaResponse = $zip->createAndUploadZip('exports', $thumbnail = false);
25.5 Getting ZIP File Path
$zipPath = $zip->getFilePath();
// Relative path to the saved ZIP
25.6 Extracting ZIP Files
$extracted = $zip->extract(
$uploadedFile, // UploadedFile instance
'exports/extracted', // Destination folder (optional)
true // Delete ZIP after extraction (optional)
);
- If
$destinationis null, the ZIP is extracted into a unique temporary folder. - Automatically creates the destination folder if it doesnβt exist.
- Throws an exception if the ZIP file does not exist or extraction fails.
- Prevents path traversal attacks for security.
Access extracted files:
$files = $extracted->files; // Relative paths
$absoluteFiles = $extracted->getAbsolutePaths(); // Full absolute paths
Cleanup extracted files manually (optional):
$extracted->cleanup(); // Deletes the temp extraction folder
Disable automatic cleanup on destruct (optional):
$extracted->disableAutoCleanup();
25.7 Notes & Best Practices
- All methods respect Laravel storage disks
- Empty key or null name β random filename (for content) or basename (for file path)
- Empty ZIPs are allowed, but may be rejected if enforced in future versions
- Use addFiles() for bulk file addition for convenience
- Always handle ExtractedZip cleanup if you want to avoid lingering temporary files.
25.8 Fluent Workflow Example
use Glc36\MaxloopKernel\Services\ZipArchiveService;
$zip = (new ZipArchiveService())
->addFiles($files)
->storeAs('my_export.zip', 'exports');
$zipPath = $zip->getFilePath();
Create & Upload to Media microservice:
$mediaResponse = $zip->createAndUploadZip('exports', $thumbnail = true);
Extract a ZIP:
$extracted = $zip->extract($uploadedFile);
$absolutePaths = $extracted->getAbsolutePaths();
$extracted->cleanup(); // Optional cleanup
26. Health Check Circuit Breaker
26.1 π Overview
An application-level circuit breaker built on spatie/laravel-health. A scheduled check detects when a dependency (starting with database connectivity) becomes unreachable, automatically flips the app into Laravel maintenance mode, and automatically restores normal service once the dependency recovers β no manual intervention, no redeploy.
Unlike ECS's own deployment circuit breaker (which restarts tasks), this is an application-level circuit breaker: killing/restarting a task does not fix a downed database. Instead, the running app fails gracefully (a maintenance page, not raw errors) and self-heals the moment the dependency comes back.
26.2 βοΈ Features
β
Automatic detect β maintenance β recover cycle, no manual intervention
β
/up stays reachable during maintenance β ALB health checks and ECS's deployment circuit breaker are unaffected
β
Configurable failure/recovery thresholds (debounce) so a single blip doesn't flap the app in and out of maintenance
β
Runtime toggle per check via health:toggle β disable a noisy check without a redeploy
β
Fail-open: a Redis/cache blip degrades to "no opinion" for that cycle rather than throwing out of the scheduled command
β
Fires a Datadog Event on every maintenance-mode transition
β
Fully opt-in per service via HEALTH_CHECK_ENABLED β bumping this package's version alone never changes a service's behavior
β
Extensible β a service can register its own checks and reactions locally, alongside what this package registers
26.3 π Installation
26.3.1 Environment Variables
Add the following to your .env:
# Master switch β must be explicitly true to activate the feature
HEALTH_CHECK_ENABLED=true
# Cache store used for the toggle state, Spatie's result store, and the
# scheduler mutex. Use a store shared across ECS tasks (e.g. redis) β a
# file-based store cannot coordinate onOneServer() across tasks at all.
HEALTH_CHECK_CACHE_STORE=redis
HEALTH_CHECK_DATABASE_ENABLED=true
HEALTH_CHECK_MAINTENANCE_BYPASS_SECRET=
# Datadog Event fired on every maintenance-mode transition
DD_API_EVENT=https://api.datadoghq.com/api/v1/events
DD_API_KEY=
DD_SERVICE=your-service-name # falls back to MICROSERVICE_NAME if unset
26.3.2 Publish Spatie's Result Store Config (Required)
health-check.php is this package's own config β it's merged in automatically, no publish needed. config/health.php is a different file, owned by spatie/laravel-health itself, and this package does not publish or override it. Without it, Spatie falls back to its own default result store β EloquentHealthResultStore, i.e. the database β no matter what HEALTH_CHECK_CACHE_STORE above says.
That default silently breaks the circuit breaker: the database check's own failure result would need to be written to the database to be recorded β right when the database is the thing that's down. Publish Spatie's own config and point it at the cache store instead:
php artisan vendor:publish --tag=health-config
Then edit config/health.php's result_stores:
'result_stores' => [
\Spatie\Health\ResultStores\CacheHealthResultStore::class => [
'store' => env('HEALTH_CHECK_CACHE_STORE', 'redis'),
],
],
Use the same store as HEALTH_CHECK_CACHE_STORE (Β§26.3.1) β the circuit breaker's consecutive failure/success counters, health:toggle state, and Spatie's own result store all need to agree on one cache backend.
26.3.3 Publish This Package's Config (Optional)
The package ships a working default health-check.php, merged in automatically β you only need to publish it if you want to customize thresholds or add your own checks/reactions:
php artisan vendor:publish --tag=maxloop-kernel-config
Once published, your local copy is authoritative for whichever keys it defines β editing it never affects the package's default or any other service.
26.3.4 Cache-Backed Maintenance Mode (Recommended for ECS)
Laravel's default file-based maintenance mode cannot coordinate across multiple ECS tasks. When APP_MAINTENANCE_DRIVER=cache is set, this package automatically binds a fail-open version of Laravel's cache maintenance mode (FailOpenCacheMaintenanceMode) β if the cache itself is unreachable, isDownForMaintenance() degrades to false instead of throwing.
APP_MAINTENANCE_DRIVER=cache
APP_MAINTENANCE_STORE=redis
26.3.5 Confirm /up Is Excluded From Maintenance
Laravel 11's health: '/up' route (registered in bootstrap/app.php's withRouting()) is automatically excluded from maintenance mode by the framework β confirm it's present so ALB/ECS health checks keep passing even while the app is down for maintenance:
return Application::configure(basePath: dirname(__DIR__))
->withRouting(
// ...
health: '/up',
)
26.4 π Usage
26.4.1 How It Runs
Once HEALTH_CHECK_ENABLED=true, everything is registered automatically β no routes/console.php wiring needed:
- The
databasecheck, gated byHEALTH_CHECK_DATABASE_ENABLEDand the runtime toggle. - An every-minute schedule running
health:checkthen reacting to the results β includingevenInMaintenanceMode(), since this is the one job that must keep running precisely while the app is down. - The fail-open cache maintenance-mode binding, if
APP_MAINTENANCE_DRIVER=cache.
26.4.2 Manually Running a Check Cycle
php artisan health:check
26.4.3 Toggling a Check at Runtime
php artisan health:toggle database off
php artisan health:toggle database on
26.4.4 Inspecting Check Status in Detail
health:list (Spatie's own command) shows only this cycle's result β and for a check that isn't due every minute (see 26.6.2), that's frequently just skipped, with no way to tell whether that's because it's toggled off or simply not due yet, and no visibility into what actually happened the last time it did run.
health:status fills that gap:
php artisan health:status
php artisan health:status --fresh # runs health:check first, then shows the fresh results
+-------------+----------------+----------+-------------------+------------------------------+----------------------+
| Check | Frequency | Eligible | Last Real Status | Last Detail | Last Real At |
+-------------+----------------+----------+-------------------+------------------------------+----------------------+
| database | * * * * * | ON | ok | ok | 2026-09-01 14:32:10 |
| environment | */15 * * * * | OFF | failed | (app.debug=1) | 2026-09-01 14:15:00 |
+-------------+----------------+----------+-------------------+------------------------------+----------------------+
- Frequency β the check's own cron expression (
* * * * *by default,*/15 * * * *for->everyFifteenMinutes(), etc.). - Eligible β evaluates every
->if()/->unless()condition on the check (via Spatie's owngetRunConditions()), independent of the cron/frequency check. This is deliberately not just thehealth:togglestate β a check can have several conditions, and the toggle is only one of them.environmentabove showsOFFbecause one of its other conditions (e.g. an environment gate) isn't met right now, even if its toggle isON. - Last Real Status / Last Detail / Last Real At β the most recent cycle this check genuinely executed (not skipped), regardless of how long ago that was β recorded automatically by
RecordLastRealResultListener, which listens for Spatie'sCheckEndedEvent(only ever dispatched for a check that actually ran). This needs no code in your own check β it's wired up once, for every check any service registers.
Reading Last Real At against Frequency tells you two different things: Eligible=OFF means a real condition is currently blocking it; Eligible=ON but Last Real At older than Frequency would normally allow means it simply hasn't been due yet β or, if it's older than that by a wide margin, that the scheduler itself may not be running at all.
26.5 π§ Configuration Reference
config/health-check.php:
use Glc36\MaxloopKernel\HealthCheck\Reactions\AppMaintenanceCircuitBreakerReaction;
return [
'enabled' => env('HEALTH_CHECK_ENABLED', false),
'cache_store' => env('HEALTH_CHECK_CACHE_STORE', 'redis'),
'checks' => [
'database' => [
'enabled_by_default' => env('HEALTH_CHECK_DATABASE_ENABLED', true),
],
],
// Checks sharing a reaction are grouped: ANY member failing triggers
// onFailure, ALL members passing simultaneously triggers onSuccess.
// failure_threshold / recovery_threshold debounce the reaction β the
// group must fail (or pass) that many consecutive cycles in a row
// before onFailure/onSuccess actually fires.
'reactions' => [
AppMaintenanceCircuitBreakerReaction::class => [
'checks' => ['database'],
'failure_threshold' => 2,
'recovery_threshold' => 2,
],
],
'bypass_secret' => env('HEALTH_CHECK_MAINTENANCE_BYPASS_SECRET'),
'datadog' => [
'api_event_url' => env('DD_API_EVENT'),
'api_key' => env('DD_API_KEY'),
'service_name' => env('DD_SERVICE', env('MICROSERVICE_NAME', 'microservice')),
],
];
Two reaction severities ship with the package β see 26.6.1 for when to use which:
AppMaintenanceCircuitBreakerReactionβ flips the app into maintenance mode.WarningCircuitBreakerReactionβ fires a Datadog warning/recovery event only, no maintenance mode.
26.6 π§© Adding Your Own Checks & Reactions
Only database ships pre-registered. A service needing another check β a dependency, an external API, a production-safety guard β writes its own Check subclass, registers it in its own AppServiceProvider, and picks one of the package's two reaction severities (or writes its own HealthCheckReaction).
26.6.1 Two Built-in Reaction Severities
AppMaintenanceCircuitBreakerReactionβ the "hard" reaction. Flips the whole app into maintenance mode. Reserved for checks where continuing to serve traffic would be actively harmful (databaseuses this today).WarningCircuitBreakerReactionβ the "soft" reaction. Fires a Datadog warning event on failure and a recovery event once healthy again, without touching maintenance mode at all. Fits a check that should be visible to the team but shouldn't take the app down by itself.
Both read onFailure()'s $consecutiveFailures/$failureThreshold (and onSuccess()'s $consecutiveSuccesses/$recoveryThreshold) to fire exactly once at the threshold crossing, not on every cycle afterward.
26.6.2 Example: a Production-Safety Check
A real example β confirms dangerous debug/profiling flags stay off in production. Runs every 15 minutes via Spatie's built-in per-check frequency support (everyFifteenMinutes()), independently of the 1-minute health:check cycle, since this doesn't need to be checked every single minute:
namespace App\HealthCheck\Checks;
use Spatie\Health\Checks\Check;
use Spatie\Health\Checks\Result;
class EnvironmentCheck extends Check
{
protected array $expected = [
'app.debug' => false,
'pulse.enabled' => false,
'telescope.enabled' => false,
];
public function run(): Result
{
$result = Result::make();
$mismatches = collect($this->expected)
->filter(fn ($expected, $key) => config($key) !== $expected)
->map(fn ($expected, $key) => "{$key}=" . var_export(config($key), true));
if ($mismatches->isNotEmpty()) {
return $result
->failed('(' . $mismatches->implode(', ') . ')')
->shortSummary('Environment misconfiguration detected');
}
return $result->ok()->shortSummary('OK');
}
}
β οΈ Read via
config(), notenv(). Oncephp artisan config:cacheruns β as a production deployment should βenv()calls outside config files returnnull, silently defeating a check exactly where it matters most.
26.6.3 Registering It
Register additively in your own AppServiceProvider::boot() β this does not conflict with the package's own registration, since Health::checks() is additive and CircuitBreaker already reacts generically to whatever's in config('health-check.reactions'), regardless of which namespace the classes live in:
use App\HealthCheck\Checks\EnvironmentCheck;
use Glc36\MaxloopKernel\HealthCheck\HealthCheckToggle;
use Spatie\Health\Checks\Check;
use Spatie\Health\Facades\Health;
public function boot(): void
{
$checks = collect([
EnvironmentCheck::new()
->name('environment')
->if(fn () => app()->environment('production'))
->if(fn () => app(HealthCheckToggle::class)->isEnabled('environment'))
->everyFifteenMinutes(),
]);
$registeredNames = Health::registeredChecks()
->map(fn (Check $check) => $check->getName())
->all();
$unregisteredChecks = $checks->reject(
fn (Check $check) => in_array($check->getName(), $registeredNames, true)
);
if ($unregisteredChecks->isNotEmpty()) {
Health::checks($unregisteredChecks->all());
}
}
Then add it to your published config/health-check.php, choosing the reaction severity β WarningCircuitBreakerReaction here, since a debug flag left on shouldn't take the app down:
use Glc36\MaxloopKernel\HealthCheck\Reactions\WarningCircuitBreakerReaction;
'checks' => [
// ...existing 'database' entry...
'environment' => [
'enabled_by_default' => env('HEALTH_CHECK_ENVIRONMENT_ENABLED', true),
],
],
'reactions' => [
// ...existing database => AppMaintenanceCircuitBreakerReaction group...
WarningCircuitBreakerReaction::class => [
'checks' => ['environment'],
'failure_threshold' => 1,
'recovery_threshold' => 1,
],
],
β οΈ Guard against duplicate registration if your provider might boot more than once (e.g. in tests) β check
Health::registeredChecks()for the name first, the same way this package's ownHealthCheckServiceProviderdoes. β οΈ Match the->if()toggle check to the check's own name βisEnabled('environment')here, not another check's name β otherwise this check's on/off state silently follows a different check's toggle instead of its own.
26.7 π Notes
- Every value in
config/health-check.phpis a template β once you publish and edit it, your local copy is authoritative for those keys; the package's default and other services are unaffected. Schedule::useCache()is a global Laravel setting β enabling this feature also switches the mutex store for every scheduled job in your app, not justhealth:check. This is deliberate (a file-based mutex cannot coordinateonOneServer()across ECS tasks at all), but worth knowing if your service has other scheduled jobs.- Reference implementation: MMS-1177 (PAS pilot), extracted into this package under MMS-1358.
27. Datadog Metrics Helper
27.1 π Overview
A small DogStatsD client for custom business metrics, shared by every Laravel service (PAS, CAS, MS, GS) so nobody wires DogStatsD by hand. It enforces the Datadog Metric Naming & Tag Standard (MMS-1453) in code. It follows the same conventions as the metrics BTE already sends (mms.bte.forward.jobs, mms.bte.pipeline.duration, ...).
APM tracing is out of scope: dd-trace-php already handles it.
27.2 βοΈ Features
increment(),decrement(),timing(),histogram(),gauge()andmeasure()through theMetricsfacade or by injectingMetricsService- Names follow
<product>.<service>.<domain>.<measure>: set the prefix once (DD_METRIC_PREFIX) and pass only<domain>.<measure>:Metrics::increment('deposit.created')βmms.pas.deposit.created serviceandenvset once fromDD_SERVICE/DD_ENV(sent asunknownif missing, warning logged if outside the standard);version,team,tenantonly when known- Tag keys are
snake_caseand must be allowlisted: other keys are dropped and a warning is logged once per key - A missing tag value is sent as
unknown, never empty Metrics::statusClass(503)β5xxfor thestatus_classtag- Never throws, never blocks (UDP / Unix datagram, non-blocking); a no-op when
DD_METRICS_ENABLED=falseor no agent is listening - No extra composer package or PHP extension needed
27.3 π Installation
27.3.1 Environment Variables
# Master switch (default false)
DD_METRICS_ENABLED=true
# <product>.<service>. prefix. Optional: when empty it is built from DD_SERVICE
# (mms-pas -> mms.pas., chatx-echo-hub -> chatx.echo_hub.)
DD_METRIC_PREFIX=mms.pas.
# Mandatory global tags (same values the agent and dd-trace-php use)
# service: mms-pas | mms-bte | mms-ocr | mms-cas | mms-ms | mms-gs | mms-tgbot
# env: production | staging | development
DD_SERVICE=mms-pas
DD_ENV=production
DD_VERSION=1.4.0 # optional
# Optional team tag
DD_METRICS_TEAM=mms
# Agent address. On ECS the sidecar listens on 127.0.0.1:8125 (the default).
DD_AGENT_HOST=127.0.0.1
DD_DOGSTATSD_PORT=8125
# or a full URL, which wins: udp://host:port or unix:///var/run/datadog/dsd.socket
# DD_DOGSTATSD_URL=
# Extra allowed tag keys for this service (comma separated)
# DD_METRICS_EXTRA_TAGS=bank,provider
27.3.2 Publish Config (Optional)
php artisan vendor:publish --tag="maxloop-kernel-config"
This writes config/metrics.php. Publish it only to change the allowlist or default tags.
27.4 π Usage
27.4.1 Facade
use Glc36\MaxloopKernel\Metrics\Facades\Metrics;
// Counter: mms.pas.deposit.created
Metrics::increment('deposit.created', ['result' => 'success', 'currency' => 'MYR']);
// Duration in ms, sent as a distribution (correct p95/p99 across tasks)
Metrics::timing('deposit.callback.duration', $elapsedMs, ['destination' => 'bte']);
// Value spread that is not a duration
Metrics::histogram('withdrawal.batch.size', count($batch));
// Current level
Metrics::gauge('deposit.pending', $pendingCount, ['currency' => 'MYR']);
// Time a block: records result:success or result:failed, rethrows exceptions
$response = Metrics::measure('gs.currencies.fetch.duration', fn () => $gs->getCurrencies());
27.4.2 Dependency Injection
use Glc36\MaxloopKernel\Metrics\MetricsService;
class DepositService
{
public function __construct(private MetricsService $metrics) {}
public function approve(Deposit $deposit): void
{
// ...
$this->metrics->increment('deposit.approved', ['currency' => $deposit->currency]);
}
}
Tags can be ['key' => 'value'] or ['key:value']. Values may be strings, numbers, booleans or enums. They are lowercased and limited to the Datadog character set; null or '' becomes unknown.
Keep metric names and tag values in one place, as the standard asks:
final class Metric
{
public const DEPOSIT_CREATED = 'deposit.created';
public const CALLBACK_DURATION = 'deposit.callback.duration';
}
enum MetricResult: string
{
case SUCCESS = 'success';
case SKIPPED = 'skipped';
case ERROR = 'error';
case FAILED = 'failed';
case EXHAUSTED = 'exhausted';
}
Metrics::increment(Metric::DEPOSIT_CREATED, ['result' => MetricResult::SUCCESS]);
Metrics::increment('http.requests', ['endpoint' => '/v1/deposits/:uuid', 'status_class' => Metrics::statusClass($status)]);
27.4.3 Testing in Your Service
use Glc36\MaxloopKernel\Metrics\Facades\Metrics;
$sent = Metrics::fake();
$this->postJson('/api/tenant/v1/deposits', $payload);
expect($sent->sentFor('mms.pas.deposit.created'))->toHaveCount(1);
27.5 π·οΈ Naming & Tag Rules
| Rule | Example |
|---|---|
<product>.<service>.<domain>.<measure>, you pass only <domain>.<measure> | deposit.created, forward.duration, forward.queue.depth |
Lowercase, . between parts, _ between words, never - (converted for you) | echo-hub β echo_hub |
Counters use a plural noun; one counter with a result tag, not one metric per outcome | pipeline.runs{result:failed} |
Timings end in .duration, in ms, sent as a distribution; levels are gauges | forward.duration, queue.depth |
service / env are global, never per call | callers cannot override them |
Tag keys are snake_case and in metrics.allowed_tags (+ DD_METRICS_EXTRA_TAGS) | result, status_class, endpoint, method_type, currency, ... |
Values come from a constant/enum; missing values are unknown | result:unknown |
| No unbounded values: user ID, transaction ID, UUID, job ID, amount, timestamp | endpoint:/v1/transactions/:uuid, status_class:5xx |
Default allowed keys: result, status, status_class, reason, scope, endpoint, method, method_type, currency, destination, source, attempt, queue, job, topic, channel, operation, type.
27.6 π Notes
- The helper writes one datagram per call. UDP is fire-and-forget: if the agent is down, the metrics are lost and the request carries on.
- Metric types:
increment/decrementβc,timingβd(distribution),histogramβh,gaugeβg. - A dropped tag is logged once per tag key per process (
Datadog metric tag dropped: not in metrics.allowed_tags), so a hot path never floods the logs. - Reference: MMS-1465 (epic MMS-1441).