This document is your complete end-to-end onboarding + technical guide for the microservice used by the UMPSA SuperApp. It is written for developers who are new to Laravel APIs and microservices, so it explains both concepts and concrete patterns from this project.
AppServiceProvider)ApiResponse & CommonFunctionApiException & HandlerGET, POST, PUT/PATCH, DELETE./api/v1/student/lejar/invoices.200, 201, 404, etc.) + JSON body.GET /api/v1/student/lejar/invoices?per_page=10&page=1Authorization: Bearer <token> (for protected endpoints).ApiResponse.Mobile only cares about the contract: request format + response JSON shape. Our job is to keep that contract stable and clear.
The UMPSA backend is split into multiple Laravel API projects:
apisz-corp), for:
Each microservice will share similar patterns:
/api/v1/...).ApiResponse).Handler + ApiException).AppServiceProvider)File: app/Providers/AppServiceProvider.php
This provider configures cross-cutting concerns that affect all APIs:
public function boot(): void
{
// Use custom token model/table for Sanctum (Oracle WEBAPP_TOKENS)
Sanctum::usePersonalAccessTokenModel(OraclePersonalAccessToken::class);
// Rate limiting
RateLimiter::for('api', function (Request $request) {
// Skip for local development
if (config('app.env') === 'local') {
return;
}
return Limit::perMinute(60)
->by($request->user()?->id ?: $request->ip())
->response(function (Request $request) {
return $this->tooManyRequests();
});
});
// Log SQL queries
DB::listen(function ($query) {
Log::info([
'sql' => $query->sql,
'bindings' => $query->bindings,
'time' => $query->time.'ms'
]);
});
$this->setupLogViewer();
$this->setupScramble();
}
Sanctum::usePersonalAccessTokenModel(OraclePersonalAccessToken::class);App\Models\PersonalAccessToken (mapped to Oracle WEBAPP_TOKENS) instead of Laravel’s default.throttle:api)RateLimiter::for('api'...) sets 60 requests per minute per user or IP.local environment.429 Too Many Requests JSON via ApiResponse::tooManyRequests().DB::listen(...) logs all SQL, parameters, and execution time.setupLogViewer() defines who can access LogViewer (currently true for everyone).setupScramble() configures OpenAPI docs with Bearer token security.ForceJsonRequestHeader – Force JSON ResponsesFile: app/Http/Middleware/ForceJsonRequestHeader.php
public function handle(Request $request, Closure $next): Response
{
$request->headers->set('Accept', 'application/json');
return $next($request);
}
auth:sanctum – Required AuthenticationUsed for endpoints that must have a logged-in user.
Route::prefix('/v1/student')->middleware(['throttle:api','auth:sanctum'])->group(function () {
// Profile
Route::get('/profile', [ProfileController::class, 'getStudentProfile']);
Route::put('/profile/edit', [ProfileController::class, 'editStudentProfile']);
Route::get('/family', [ProfileController::class, 'getStudentFamily']);
Route::post('/family/create', [ProfileController::class, 'createFamilyMember']);
Route::put('/family/edit/{id}', [ProfileController::class, 'editStudentFamily']);
Route::get('/e-confirmation', [ProfileController::class, 'getStudentEConfirmation']);
// Lejar
Route::group(['prefix' => '/lejar'], function () {
Route::get('/total-invoice', [LejarController::class, 'totalInvoice']);
Route::get('/balance', [LejarController::class, 'balance']);
Route::get('/invoices', [LejarController::class, 'invoices']);
Route::get('/student-payments', [LejarController::class, 'studentPayments']);
Route::get('/sponsor-payments', [LejarController::class, 'sponsorPayments']);
Route::get('/vouchers', [LejarController::class, 'vouchers']);
});
});
Or
Route::get('/profile', [ProfileController::class, 'getStudentProfile'])->middleware('auth:sanctum');
Authorization: Bearer <token> in the header.$request->user() returns StudentMain or StaffMain instance linked to the token.OptionalSanctumAuth – Optional Auth + Role Detection (auth.optional)File: app/Http/Middleware/OptionalSanctumAuth.php
Registered in: bootstrap/app.php as auth.optional.
public function handle(Request $request, Closure $next): Response
{
$user = null; $role = 'PUBLIC'; $userType = null;
$token = $request->bearerToken() ?? $request->input('token');
if ($token) {
$parts = explode('|', $token);
if (count($parts) === 2) {
$hash = hash('sha256', $parts[1]);
$pat = PersonalAccessToken::where('token', $hash)->first();
if ($pat) {
$type = $pat->tokenable_type;
$id = $pat->tokenable_id;
if (str_contains($type, 'StudentMain')) {
$user = StudentMain::find($id);
if ($user) { $role = 'STUDENT'; $userType = 'student'; }
} elseif (str_contains($type, 'StaffMain')) {
$user = StaffMain::find($id);
if ($user) { $role = 'STAFF'; $userType = 'staff'; }
}
}
}
}
$request->merge([
'auth_user' => $user,
'auth_role' => $role, // PUBLIC / STUDENT / STAFF
'auth_user_type' => $userType, // null / 'student' / 'staff'
]);
$request->setUserResolver(fn () => $user);
return $next($request);
}
Purpose:
role = PUBLIC.role = STUDENT, userType = 'student', $request->user() = StudentMain.role = STAFF, userType = 'staff', $request->user() = StaffMain.Usage in routes:
Route::get('/calendar/all-by-month', [CalendarController::class, 'getEventsByMonth'])
->middleware('auth.optional');
Route::get('/events/upcoming', [EventController::class, 'upcoming'])
->middleware('auth.optional');
Route::apiResource('/events', EventController::class)
->middleware('auth.optional');
Route::group(['prefix' => '/announcements', 'middleware' => 'auth.optional'], function () {
// ...
});
In controllers (how to filter data by role):
$user = $request->user(); // null / StudentMain / StaffMain
$role = $request->input('auth_role'); // PUBLIC / STUDENT / STAFF
$userType = $request->input('auth_user_type'); // null / student / staff
role === 'STUDENT' → filter events for student’s faculty/program.role === 'STAFF' → show staff-only announcements.role === 'PUBLIC' → show only public data.This middleware is key for endpoints that must behave differently for guest vs logged-in vs role.
File: routes/api.php
Route::group(['prefix' => '/v1/general', 'middleware' => 'throttle:api'], function () {
// /api/v1/general/auth/*
// /api/v1/general/themes
// /api/v1/general/calendar/*
// /api/v1/general/events/*
// /api/v1/general/contact-directory/*
// /api/v1/general/common/*
});
Route::group(['prefix' => '/v1/student', 'middleware' => 'throttle:api'], function () {
// /api/v1/student/profile/*
// /api/v1/student/lejar/*
});
Rules:
/api/v1/...:
v1 = API version. If you introduce breaking changes, create v2 and keep v1 for old apps./v1/general/.../v1/student/.../v1/academic, /v1/finance, /v1/hr.throttle:api → rate limiting at module level.auth:sanctum or auth.optional at route/group level.Route::fallback(function () {
return Handler::handleRouteNotFoundException();
});
/api/* route returns a standard JSON 404 using ApiResponse::notFound(ApiException::ROUTE_NOT_FOUND).ThemesController@indexFile: app/Http/Controllers/Api/ThemesController.php
public function index(Request $request)
{
try {
$themes = Themes::with('pageBackgrounds')
->where('wt_start_date', '<=', now())
->where('wt_end_date', '>=', now())
->whereIn('wt_status_id', [Themes::$ActiveStatus, Themes::$DefaultStatus])
->whereHas('pageBackgrounds', function ($query) {
$query->whereIn('wpb_page', [
PageBackgrounds::$PAGE_BACKGROUND_IMAGE,
PageBackgrounds::$PAGE_BACKGROUND_IMAGE_LOGIN,
]);
})
->get();
if ($themes->isEmpty()) {
throw new ModelNotFoundException(ApiException::RECORD_NOT_FOUND);
}
return ApiResponse::success(
'Themes retrieved successfully',
ThemesResource::collection($themes)
);
} catch (\Exception $e) {
return Handler::handleApiException($request, $e);
}
}
Pattern to follow (no pagination):
with, whereHas).->isEmpty()) →throw new ModelNotFoundException(ApiException::RECORD_NOT_FOUND);ApiResponse::success('Message', Resource::collection($items)).try/catch and delegate error to Handler::handleApiException().LejarController@invoicesFile: app/Http/Controllers/Api_Student/LejarController.php
public function invoices(Request $request)
{
try {
$studentId = $request->user()->sm_student_id;
$perPage = $request->input('per_page', ApiResponse::$perPage);
$page = $request->input('page', 1);
$invoices = CinvoiceHead::select(
'CH_INVOICE_NO as INVOICE_NO',
'CH_ACADEMIC_YEAR as ACADEMIC_YEAR',
'CH_INVOICE_DESC as INVOICE_DESCRIPTION',
'CH_TOTAL_AMT as AMOUNT'
)
->where('CH_CUST_ID', $studentId)
->whereIn('CH_STATUS', ['APPRV', 'PAID'])
->where('CH_CUST_TYPE', 'STUD')
->paginate($perPage, ['*'], 'page', $page);
if ($invoices->isEmpty()) {
throw new ModelNotFoundException(ApiException::RECORD_NOT_FOUND);
}
return ApiResponse::paginated(
InvoiceResource::collection($invoices),
'Invoices retrieved successfully'
);
} catch (\Exception $e) {
return Handler::handleApiException($request, $e);
}
}
How pagination works (step-by-step for juniors):
Request from mobile:
GET /api/v1/student/lejar/invoices?per_page=10&page=2
per_page – page size.page – which page.Controller reads params:
$perPage = $request->input('per_page', ApiResponse::$perPage); // default 15
$page = $request->input('page', 1);
Use ->paginate(...) on query:
$invoices = CinvoiceHead::...->paginate($perPage, ['*'], 'page', $page);
If no data on that page ($invoices->isEmpty()) → throw ModelNotFoundException.
Return paginated response via trait:
public static function paginated($paginator, string $message = 'Success'): JsonResponse {
return response()->json([
'status' => 'success',
'message' => $message,
'data' => $paginator->items(),
'pagination' => [
'first' => $paginator->url(1),
'last' => $paginator->url($paginator->lastPage()),
'next' => $paginator->nextPageUrl()
? $paginator->nextPageUrl() . '?per_page=' . $paginator->perPage()
: null,
'prev' => $paginator->previousPageUrl()
? $paginator->previousPageUrl() . '?per_page=' . $paginator->perPage()
: null,
'total' => $paginator->total(),
'per_page' => $paginator->perPage(),
'next_page' => $paginator->nextPageUrl() ? $paginator->currentPage() + 1 : null,
'prev_page' => $paginator->previousPageUrl() ? $paginator->currentPage() - 1 : null,
'current_page' => $paginator->currentPage(),
'last_page' => $paginator->lastPage(),
'from' => $paginator->firstItem(),
],
], Response::HTTP_OK);
}
Mobile receives JSON like:
{
"status": "success",
"message": "Invoices retrieved successfully",
"data": [
/* current page invoices */
],
"pagination": {
"first": "...page=1",
"last": "...page=n",
"next": "...page=3&per_page=10",
"prev": "...page=1&per_page=10",
"total": 123,
"per_page": 10,
"next_page": 3,
"prev_page": 1,
"current_page": 2,
"last_page": 13,
"from": 11
}
}
For any new paginated API:
per_page & page.->paginate($perPage, ['*'], 'page', $page).isEmpty() → throw ModelNotFoundException(ApiException::RECORD_NOT_FOUND).ApiResponse::paginated(Resource::collection($paginator), 'Message').ApiResponse & CommonFunctionApiResponseFile: app/Traits/ApiResponse.php
Central place to standardize all API JSON responses:
trait ApiResponse
{
public static $perPage = 15;
public static $limit = 5;
public static function success(string $message = 'Success', $data = null, int $status = 200): JsonResponse { ... }
public static function error(string $message = 'Error', $errors = null, int $status = 400): JsonResponse { ... }
public static function validationError(string $message = 'Validation failed', $errors = null): JsonResponse { ... }
public static function unauthorized(string $message = 'Unauthorized'): JsonResponse { ... }
public static function forbidden(string $message = 'Forbidden'): JsonResponse { ... }
public static function notFound(string $message = 'Resource not found'): JsonResponse { ... }
public static function serverError(string $message = 'Internal server error'): JsonResponse { ... }
public static function created(string $message = 'Resource created successfully', $data = null): JsonResponse { ... }
public static function noContent(): JsonResponse { ... }
public static function paginated($paginator, string $message = 'Success'): JsonResponse { ... }
public static function tooManyRequests(string $message = 'Too many requests'): JsonResponse { ... }
}
Controller usage examples:
return ApiResponse::success('Countries retrieved successfully', CountryResource::collection($countries));
return ApiResponse::paginated(InvoiceResource::collection($invoices), 'Invoices retrieved successfully');
Handler (see below).CommonFunctionUsed in ThemesController (e.g. parseDate), and can be extended for reusable helpers (date formatting, etc.). Pattern: put common small helpers in traits instead of repeating across controllers.
ApiException & HandlerApiException – Central Error MessagesFile: app/Exceptions/ApiException.php
class ApiException extends Exception
{
// Authentication errors
const UNAUTHORIZED = 'You are not authorized to access this API.';
const INVALID_CREDENTIALS = 'The provided credentials are incorrect.';
const TOKEN_EXPIRED = 'Your session has expired. Please login again.';
// Validation
const VALIDATION_ERROR = 'The provided data is invalid.';
const RECORD_NOT_FOUND = 'The requested data was not found.';
// Database
const DATABASE_ERROR = 'A database error occurred. Please try again.';
const DATABASE_CONNECTION_ERROR = 'Unable to connect to database. Please try again later.';
const DUPLICATE_ENTRY = 'This record already exists.';
const FOREIGN_KEY_CONSTRAINT = 'Cannot delete this record as it is being used elsewhere.';
// Resource / permission / server / etc...
const ROUTE_NOT_FOUND = 'The requested route does not exist.';
// ...
public static function throw(string $message, int $code = 400): self
{
return new self($message, $code);
}
}
Usage:
throw ApiException::throw(ApiException::INVALID_REQUEST, 400);
throw new ModelNotFoundException(ApiException::RECORD_NOT_FOUND);
Handler – Centralized API Error ResponsesFile: app/Exceptions/Handler.php
public function render($request, Throwable $e)
{
// Only handle API requests
if ($request->is('api/*') || $request->expectsJson()) {
return $this->handleApiException($request, $e);
}
return parent::render($request, $e);
}
handleApiException maps exceptions to ApiResponse:
AuthenticationException → 401 + UNAUTHORIZED.ValidationException → 422 + VALIDATION_ERROR + validation errors.ModelNotFoundException → 404 + RECORD_NOT_FOUND.NotFoundHttpException → 404 + RESOURCE_NOT_FOUND.MethodNotAllowedHttpException → 405.ThrottleRequestsException → 429 + TOO_MANY_REQUESTS.QueryException → DB error mapping (DATABASE_ERROR, DUPLICATE_ENTRY, FOREIGN_KEY_CONSTRAINT, etc.).ApiException → use its message + code.APP_DEBUG=true → returns actual message.500 + INTERNAL_SERVER_ERROR.Controller pattern:
try {
// main logic
} catch (\Exception $e) {
return Handler::handleApiException($request, $e);
}
You use Oracle with multiple users/schemas. Each user holds related tables:
cmsadmin.student_main, staff_main, receipts, sponsor payments, vouchers).In Laravel:
In config/database.php, define multiple Oracle connections (e.g. oracle_cmsadmin, oracle_ump_dev, oracle_asis).
In each model:
class StudentMain extends Model
{
protected $connection = 'oracle_cmsadmin';
protected $table = 'cmsadmin.student_main';
// ...
}
Many controllers already use schema-qualified tables, e.g.:
$std = StudentMain::with('studentPhoto', 'state', 'course', 'faculty')->where('sm_student_id', $request->user()->sm_student_id)->first();
Connect to Oracle dev DB (45.127.5.74, xepdb1) with:
CMSADMIN:
45.127.5.741521xepdb1cmsadmintwoqaumpdevUMP_DEV:
45.127.5.741521xepdb1ump_devtwoqaumpdevASIS:
45.127.5.741521xepdb1asistwoqaumpdevUse this to:
For POST/PUT endpoints, use Form Request classes instead of inline validation.
Create:
docker exec -it apisz-corp php artisan make:request StoreSomethingRequest
In controller:
public function store(StoreSomethingRequest $request)
{
$data = $request->validated();
// use $data...
}
All validation failures are handled by ValidationException → ApiResponse::validationError (422).
Inline validation is still used in some places (e.g. ThemesController@show), but the recommended style for new APIs is separate Request classes.
Resources shape outgoing JSON:
ThemesResource::collection($themes)InvoiceResource::collection($invoices)StudentPaymentResource, SponsorPaymentResource, VoucherResource, StateResource, CountryResource.Purpose:
CH_INVOICE_NO) to API fields (INVOICE_NO).Create new Resource:
docker exec -it apisz-corp php artisan make:resource StateResource
Use in controller:
return ApiResponse::success(
'States retrieved successfully',
StateResource::collection($states)
);
File: docker-compose.yml
services:
app:
platform: linux/amd64
build:
context: .
dockerfile: .docker/Dockerfile
container_name: apisz-corp
volumes:
- ./application:/var/www:cached
- /var/www/vendor
environment:
- ORACLE_HOME=/usr/lib/oracle/21/client64
- LD_LIBRARY_PATH=/usr/lib/oracle/21/client64/lib
- TNS_ADMIN=/opt/oracle/instantclient/network/admin
networks:
- apisz-corp-net
webserver:
image: nginx:alpine
container_name: nginx_server-apisz-corp
ports:
- "8001:80"
volumes:
- ./application:/var/www:cached
- ./.docker/nginx/default.conf:/etc/nginx/conf.d/default.conf
- ./.docker/nginx/logs:/var/log/nginx
depends_on:
- app
networks:
- apisz-corp-net
networks:
apisz-corp-net:
driver: bridge
app – PHP-FPM + Laravel.webserver – nginx, exposes port 8001 to host.Start containers:
docker compose up -d
Stop:
docker compose down
Local dev root URL (General project):
http://localhost:8001http://localhost:8001/docs/apihttp://localhost:8001/log-viewerAlways include container prefix:
docker exec -it apisz-corp php artisan <command>
Common commands:
docker exec -it apisz-corp php artisan optimize
docker exec -it apisz-corp php artisan make:controller Api/MyFeatureController
docker exec -it apisz-corp php artisan make:model Models/MyModel -m
docker exec -it apisz-corp php artisan make:resource MyFeatureResource
docker exec -it apisz-corp php artisan make:request StoreMyFeatureRequest
docker exec -it apisz-corp php artisan make:migration create_my_feature_table
docker exec -it apisz-corp php artisan config:cache
docker exec -it apisz-corp php artisan route:cache
docker exec -it apisz-corp php artisan cache:clear
Important: For other microservices, only the container name changes (e.g.
apisz-student).
apisz-corp):
http://localhost:8001https://psoh-staging.twoq.my:8092https://psoh-staging.twoq.my:8092/docs/api
calendar.getEventsByMonthhttps://api-corp-stag.umpsa.edu.myhttps://api-corp-stag.umpsa.edu.my/docs/apihttps://api-corp-stag.umpsa.edu.my/log-viewerImportant:
UMP staging (
api-corp-stag.umpsa.edu.my) is only accessible inside UMP WiFi/network. Outside UMP you must use VPN or ask someone inside to access.
2Q server:
45.127.5.74root22On Windows – recommended: MobaXterm.
On macOS – recommended: Termius (or Terminal + ssh).
Deployment steps:
Go to project folder (General project)
cd /var/www/UMP/apisz-corp/
/var/www/UMP/apisz-student/.Check git status
git status
Pull latest code
git pull
Optimize inside Docker container
docker exec -it apisz-corp php artisan optimize
Verify on 2Q staging
https://psoh-staging.twoq.my:8092/docs/apigit branch).optimize.This is the my personal style already use – new devs should follow this exactly.
Api list – Comparison UIExisting query sheetERD Drive (Modul Umum / Modul Pelajar / Modul Staff)If a table used in query is not in dev DB:
Check ERD for table structure (columns, types, PK/FK).
Copy table + column details and use AI (or manual) to generate:
$connection and $table.Run migration + seeder:
docker exec -it apisz-corp php artisan migrate --path=database/migrations/..(Your Migration file name)..php
docker exec -it apisz-corp php artisan db:seed --class=YourSeeder
If ERD doesn’t have the table mentioned in query:
+60 12-678 9584.To inspect real staging data (UMP staging DB):
1061984017.019 917 0117 to approve.Create controller (if needed):
docker exec -it apisz-corp php artisan make:controller Api/ModuleNameController
Add route in routes/api.php:
/v1/general for General/Corporate./v1/student for Student (in other project).throttle:api (group).auth:sanctum for protected.auth.optional for guest + role-based behavior.Implement method in controller using skeletons:
ThemesController@index.LejarController@invoices.Create Resource to shape JSON:
docker exec -it apisz-corp php artisan make:resource Module/FeatureResource
For POST/PUT, create Request class:
docker exec -it apisz-corp php artisan make:request StoreFeatureRequest
Use OptionalSanctumAuth correctly if role-based filtering is needed for those api that does’nt use auth:sanctum:
$request->user(), auth_role, auth_user_type to filter.Test locally via http://localhost:8001/docs/api (Scramble UI) or Postman.
Ensure:
status, message, data, optional pagination).401, 404, 422, 429, etc.).Update API list sheet:
API Development Status to Done.API Testing Status (e.g. In Progress, Done).After push to Git, deploy to 2Q staging (see section 12.2) so mobile frontend team can integrate.
DB / Query / ERD issues
+60 12-678 9584 (Whatsapp)Access to UMP staging DB (real data)
1061984017019 917 0117.Understand patterns:
AppServiceProvider → rate limit, Sanctum, logging, docs.ForceJsonRequestHeader, auth:sanctum, auth.optional.ApiResponse, ApiException, Handler → do NOT invent your own JSON formats.ThemesController@index → non-paginated skeleton.LejarController@invoices → paginated skeleton.Always:
(POST / PUT).try/catch and delegate to Handler.Use the shared docs:
Api list sheetERD DriveExisting query reference# Start containers
docker compose up -d
# Stop containers
docker compose down
# View logs
docker logs apisz-corp
docker logs nginx_server-apisz-corp
# Optimize
docker exec -it apisz-corp php artisan optimize
# Make controller
docker exec -it apisz-corp php artisan make:controller Api/MyController
# Make model with migration
docker exec -it apisz-corp php artisan make:model Models/MyModel -m
# Make resource
docker exec -it apisz-corp php artisan make:resource MyResource
# Make request
docker exec -it apisz-corp php artisan make:request StoreMyRequest
# Make migration
docker exec -it apisz-corp php artisan make:migration create_my_table
# Run migration
docker exec -it apisz-corp php artisan migrate
# Cache
docker exec -it apisz-corp php artisan config:cache
docker exec -it apisz-corp php artisan route:cache
docker exec -it apisz-corp php artisan cache:clear
# SSH into server
ssh root@45.127.5.74 -p 22
# Navigate to project
cd /var/www/UMP/apisz-corp/
# Git operations
git status
git pull
# Optimize
docker exec -it apisz-corp php artisan optimize
Document Version: 1.0
Last Updated: 2025
Created By: Satiya Ganes (sG)