Case study · BCA final-year project
ResQNet
An animal rescue, adoption, donation and health-management platform for Nepal. One Laravel backend serves a React Native (Expo) mobile app for the public and web dashboards for rescue organisations and administrators. It is live in production, and I lead and maintain its backend.
ResQNet demo video coming soon
A recorded walkthrough of the live platform will appear here.
01
Overview and problem
Animal rescue involves several groups who rarely share a system: members of the public who find an injured or stray animal, the shelters and rescue organisations that respond, and the people who adopt or donate. Without a shared system, reports, adoption applications, donations and animal health records end up spread across phone calls and separate tools.
ResQNet puts these workflows on one backend. Each type of user gets an interface suited to how they work: a mobile app for the public, and web dashboards for organisations and platform administrators.
| Role | Interface | What they can do |
|---|---|---|
| Public user | Mobile app auth:sanctum |
Report a rescue with location and photo, browse and save pets, apply to adopt, donate through Khalti, receive notifications. |
| Organisation | Web dashboard role:organization |
Claim rescue cases and dispatch team members, manage pets and their medical records, vaccinations and medications, review adoptions, manage staff and vets, see donations. |
| Super admin | Web dashboard role:super_admin |
Approve or reject organisation registrations, manage users and pets, review donations (with Excel export), view platform analytics. |
02
My role
I was backend lead in a three-member team. The React Native (Expo) mobile app is a separate client that consumes the API I designed. On the backend I was responsible for:
- REST APIs for rescue reports, adoption applications, health records and organisations
- Token authentication with Laravel Sanctum and role-based access with Spatie Permission
- Khalti payment integration, Firebase Cloud Messaging and real-time updates over Laravel Reverb
- Database structure, including the rescue audit trail and polymorphic media relationships
- QR codes for pet identification
- The CircleCI pipeline for automated testing and deployment, and hosting on Heroku
- DNS, SMTP and DMARC configuration for the production domain and email
- Ongoing maintenance of the live platform: investigating production issues, fixing defects, deploying updates
03
Backend architecture and API development
ResQNet is a single Laravel 11 codebase with two interfaces: a versioned JSON API under
api/v1 for the mobile app, and server-rendered Blade dashboards under
/organization/* and /admin/*. Controllers are split by audience
(Api/, Web/Organization/, Web/Admin/, Auth/). Input is
validated with Form Request classes and responses are shaped with API Resources, so mobile clients get a
consistent JSON format.
API surface
The mobile API is grouped by resource. Public routes cover registration, login, OTP verification and
browsing pets. Everything else sits behind auth:sanctum, and the user-specific routes add a
custom is_active middleware that blocks suspended accounts.
Route::prefix('v1')->group(function () {
Route::prefix('auth')->name('api.auth.')->group(function () {
Route::post('/register', [AuthController::class, 'register'])->name('register');
Route::post('/login', [AuthController::class, 'login'])->name('login');
Route::post('/verify-otp', [AuthController::class, 'verifyOtp'])->name('verify-otp');
// …
});
Route::middleware(['auth:sanctum'])->group(function () {
Route::middleware(['is_active'])->group(function () {
Route::prefix('rescues')->name('api.rescues.')->group(function () {
Route::get('/', [RescueController::class, 'index'])->name('index');
Route::post('/', [RescueController::class, 'store'])->name('store');
Route::get('/{report}', [RescueController::class, 'show'])->name('show');
});
Route::prefix('donations')->name('api.donations.')->group(function () {
Route::post('/khalti/initiate', [DonationController::class, 'khaltiInitiate'])->name('khalti.initiate');
Route::post('/khalti/verify', [DonationController::class, 'khaltiVerify'])->name('khalti.verify');
// …
});
// user, notifications, saved-pets, adoptions …
});
});
});
Rescue case lifecycle
The main workflow is a rescue case moving through four states. Every transition is validated against an explicit table of allowed moves, recorded in a history table, and broadcast to dashboards in real time.
- reportedA public user submits a report from the app with location, urgency and an optional photo. Organisations see it appear live.
- assignedAn organisation claims the case. Only one organisation can claim it (see section 06).
- in_progressThe organisation dispatches team members, who get an email with a signed link to the case details.
- resolvedA pet profile and intake medical record are created in the same transaction, and the reporter is notified.
$allowed = [
'reported' => ['assigned'],
'assigned' => ['in_progress', 'resolved'],
'in_progress' => ['resolved'],
];
$current = $report->status;
if (!isset($allowed[$current]) || !in_array($validated['status'], $allowed[$current])) {
return response()->json([
'message' => "Cannot transition from '{$current}' to '{$validated['status']}'.",
], 422);
}
DB::transaction(function () use ($report, $validated, $current, $orgId, &$pet) {
// on 'resolved': create the Pet (status in_care) and an intake MedicalRecord …
$report->update($updateData);
RescueCaseHistory::create([
'rescue_report_id' => $report->id,
'from_status' => $current,
'to_status' => $validated['status'],
'changed_by' => auth()->id(),
// …
]);
});
$this->safeBroadcast(new CaseStatusUpdatedEvent($report, $current));
04
Authentication and security
The two interfaces use different authentication: the mobile app uses Laravel Sanctum personal access tokens, and the dashboards use session authentication. Authorisation is layered on top of that.
- Roles with Spatie Permission:
public_user,organizationandsuper_admin, enforced throughrole:route middleware. - Policies for pets, rescue reports, adoption requests, donations and vets, so an organisation can only act on its own records.
- Account status: the custom
CheckIsActivemiddleware blocks suspended users from the user-specific API routes (rescues, adoptions, donations, notifications and profile). - OTP verification: six-digit email codes stored in the cache for 10 minutes, with a 60-second resend cooldown, for registration and password reset.
- Signed URLs give dispatched team members access to one case without a login, and the link can't be tampered with.
- Rate limiting on organisation password changes: repeated failures log the user out and send them to password reset.
- Channel authorisation so WebSocket subscribers only receive events they're entitled to (below).
Broadcast::channel('organization.{orgId}', function ($user, $orgId) {
if ($user->organization && $user->organization->id === (int) $orgId) {
return ['id' => $user->id, 'name' => $user->name];
}
return false;
});
Broadcast::channel('rescue-updates', function ($user) {
if ($user->hasRole('organization') || $user->hasRole('super_admin')) {
return ['id' => $user->id, 'name' => $user->name, 'organization_id' => $user->organization_id];
}
return false;
});
Broadcast::channel('user.{userId}', function ($user, $userId) {
return $user->id === (int) $userId;
});
05
Third-party integrations
Real-time updates with Laravel Reverb
Domain events such as NewRescueReportEvent, CaseClaimedEvent,
CaseStatusUpdatedEvent and AdoptionStatusUpdatedEvent are broadcast through Reverb.
Organisation dashboards listen with Laravel Echo, so a new report appears on the map without a refresh. The
mobile app authenticates its private channel through a Sanctum-protected /api/broadcasting/auth
endpoint. Notifications are also stored in the database, so users can catch up on anything they missed.
Khalti payments
Donations use Khalti's ePayment flow. The backend starts a payment and returns the payment URL to the app. After the user pays, the backend looks the payment up with Khalti directly before marking the donation complete, rather than trusting the status in the redirect. The lookup response is stored with the donation for auditing, and admins can send donors an appreciation email from the dashboard.
public function lookupPayment(string $pidx): array
{
$response = Http::withHeaders([
'Authorization' => 'Key ' . $this->secretKey,
'Content-Type' => 'application/json',
])->post($this->baseUrl . 'epayment/lookup/', [
'pidx' => $pidx,
]);
if ($response->failed()) {
throw new RuntimeException(
'Khalti lookup failed: ' . ($response->json()['detail'] ?? $response->body())
);
}
return $response->json();
}
public function isCompleted(array $lookupResult): bool
{
return ($lookupResult['status'] ?? '') === 'Completed';
}
Firebase Cloud Messaging
Users, organisations and organisation team members each store an FCM device token. The app updates it
through POST /api/v1/user/fcm-token, and it's cleared on logout so a signed-out device stops
receiving pushes. Each model routes push notifications to its token through
laravel-notification-channels/fcm.
Email, media and QR codes
- Mail: organisation approval, rejection and suspension emails, donation appreciation, team dispatch and OTP codes.
- Spatie MediaLibrary stores pet photos and organisation registration documents.
- QR codes: each pet gets an ID like
RQN-00042when it is created, rendered as an SVG QR code that opens the pet's public page.
06
Database and infrastructure
The schema is built from incremental Laravel migrations on MySQL 8. The core entities are users and their adoption profiles, organisations and their team members, pets with medical records, vaccinations and medications, vets, rescue reports with assignments and history, adoption requests, donations and saved pets, plus Spatie's role, permission and media tables.
- Audit trail:
rescue_case_historiesrecords each status change with who made it and why; soft deletes keep history for major entities. - Polymorphic media: photos and documents attach to any model through MediaLibrary's
mediatable instead of one image table per entity. - Data integrity under concurrency: claiming a rescue case is a single conditional update, so two organisations can't both claim the same case.
$updated = RescueReport::where('id', $report->id)
->whereNull('organization_id')
->where('status', 'reported')
->update([
'organization_id' => $orgId,
'status' => 'assigned',
'assigned_at' => now(),
]);
if ($updated === 0) {
return response()->json([
'message' => 'This case has already been claimed by another organization.',
], 409);
}
07
Deployment and ongoing maintenance
Every push runs a CircleCI job on PHP 8.3 with a MySQL 8 service container: install Composer and npm
dependencies, build the frontend assets, run migrations and run the PHPUnit test suite. The feature tests
cover authentication, password reset, the adoption and saved-pet APIs, pets, vets, health records and the
rescue workflow. Only when that passes on main does a second job deploy to Heroku, where the app
runs on the heroku-php-apache2 buildpack.
workflows:
version: 2
build_test_deploy:
jobs:
- build_and_test
- deploy:
requires:
- build_and_test
filters:
branches:
only: main
Beyond the pipeline, I configured DNS for the production domain and SMTP with DMARC so that transactional email, such as OTP codes and dispatch notices, is delivered reliably. Since launch I've kept maintaining the live platform: investigating production issues, fixing defects and deploying updates through the same pipeline.