Notifications: notification_preferences + Laravel notifications inbox. SmsChannel adapter. RoutesByPreferences trait reads per-user toggles. HouseholdApproved/Rejected, QrBalanceLow, CodesPurchased notifications wired via auto-discovered listeners or direct dispatch. Payments: payments table + PaymentDriver interface. ManualPaymentDriver works out of the box; PayMongoDriver activates when PAYMONGO_SECRET_KEY is set, falls back to manual otherwise. Resident initiates code purchase, admin can mark paid manually, webhook applies real provider events. Fulfillment runs StoreOperations::sellToHousehold. Live tracking (HTTP polling): truck_location_history with SPATIAL INDEX. Driver POST writes history, updates trucks.last_known_coordinates, caches in Redis, flags geofence-trigger when entering active trip dumpsite. Admin GET /admin/live/trucks returns active positions. Reverb broadcast deferred. Flow fixes: - QrAllocator now idempotent — re-approving a household won't re-dispense free codes. - arrive-dumpsite enforces dumpsite geofence via ST_Contains; can be bypassed with override_geofence: true. 171 feature tests passing. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
17 KiB
Verde Backend (Laravel 11)
See ../CLAUDE.md for project-wide context. This file covers backend-specific
conventions.
Stack
- Laravel 11.51, PHP 8.4
- MySQL 9.2 via DBngin (host 127.0.0.1, root user, no password, db
verde, test dbverde_testing) - Sanctum for token auth
- Spatie: laravel-permission, laravel-activitylog, laravel-model-states
- matanyadaev/laravel-eloquent-spatial for MySQL POINT/POLYGON
Run
php artisan serve --host=127.0.0.1 --port=8000
Health: GET http://127.0.0.1:8000/api/v1/health
Tests
php artisan test
Tests use the verde_testing database (configured in phpunit.xml) with
RefreshDatabase. SMS driver in tests is fake (an in-memory recorder).
DBngin MySQL CLI
The mysql binary is not on PATH. Use:
/Users/Shared/DBngin/mysql/9.2.0_arm/bin/mysql -u root -h 127.0.0.1
Conventions
API Response Envelope
Every API response uses:
{
"success": true|false,
"data": <payload or null>,
"message": "<human readable or null>",
"errors": <validation errors map or null>,
"meta": { ... }
}
Implemented in App\Http\Responses\ApiResponse. Controllers extend
App\Http\Controllers\Api\V1\ApiController and use $this->ok(),
$this->created(), $this->fail(), $this->notFound(), etc.
Routing
- All API routes under
/api/v1(prefix configured inbootstrap/app.php) - Controllers in
app/Http/Controllers/Api/V1/... - Route names follow
api.v1.<resource>.<action>
Auth
- Sanctum bearer tokens for mobile (token in
Authorization: Bearer <t>) - Auth endpoints under
/api/v1/auth/*— seeroutes/api.php - Role middleware alias
role:<role>[,<role>...](defined inApp\Http\Middleware\EnsureUserHasRole, registered inbootstrap/app.php) - Spatie roles seeded by
RoleSeeder(admin/resident/driver/helper/scanner/ store_partner). Theusers.rolecolumn is the discriminator; Spatie roles mirror it for permission checks.
OTP
- Codes stored hashed in
otp_codes(purpose, destination, attempts cap = 5, expires_at, consumed_at). SeeApp\Models\OtpCode. App\Services\Otp\OtpServiceissues + verifies codes with TTL/cooldown controlled byservices.otp.*config.- SMS goes through
App\Services\Sms\SmsServicecontract. Drivers:LogSmsService(default — writes to log),SemaphoreSmsService(prod),FakeSmsService(tests). Driver chosen bySMS_DRIVERenv. - In
local/testingenvironments, register/forgot/resend responses includedata.debug_codefor easy manual testing — strip inproduction.
Exceptions
Global handlers in bootstrap/app.php ensure ValidationException,
AuthenticationException, NotFoundHttpException, and HttpExceptionInterface
all return the standard envelope on api/* requests. Don't write per-controller
try/catch for these — let them bubble.
Models
- All models use SoftDeletes unless noted otherwise
- User model has role constants:
User::ROLE_ADMIN,User::ROLE_RESIDENT, etc. - Auto-generated UUID on User creation via the
booted()hook - Activitylog enabled on User; add
LogsActivityto other models that need audit
Migrations
- snake_case plural table names
- bigint id PK, separate
uuidfor public-facing identifiers - Spatial columns use
Point/Polygontypes (SRID 4326) - Add
SPATIAL INDEXon geometry columns
Testing
Use feature tests with RefreshDatabase. Aim for happy path + 2-3 error cases
per endpoint. Tests live in tests/Feature/Api/V1/. For routes that send SMS,
bind FakeSmsService via $this->app->instance(SmsService::class, ...) in
setUp().
Module Status
- Module 1: Foundation & Auth — complete
- register / login / logout / refresh / me
- forgot-password / reset-password
- OTP verify / resend (Semaphore + log + fake drivers)
- Role middleware + RoleSeeder + AdminUserSeeder
- Module 2: Geographic Data — complete
- PSGC tables: regions, provinces, cities_municipalities, barangays
- Spatial columns via Laravel 11 native
geometry()(subtype: polygon/point, SRID 4326), cast to matanyadaev Polygon/Point objects on the model - Cascading dropdown endpoints:
GET /geo/{regions,provinces,cities,barangays} - GPS resolve:
POST /geo/resolve {lat,lng}→ barangay (uses ST_Contains against barangay polygons; cached barangay id by 1e-6 lat/lng key) - Service area CRUD:
/service-areas(admin-only), barangay attach/detach - SamplePsgcSeeder (NCR + 5 cities + 7 barangays with rough box polygons)
php artisan psgc:import {file.json}to ingest a full dataset later
- Module 3: User Management — complete
- 5 profile tables: resident_profiles, driver_profiles, helper_profiles, scanner_profiles, store_partner_profiles (1:1 to users, soft deletes)
- Driver/helper/scanner/store_partner profiles carry verification_status (pending/approved/rejected) + verified_at/by + rejection_reason
User::profileRelation()/User::profile()dispatches to the right profile by role;User::profileModelForRole()for create-on-registerHasProfileVerificationtrait shared by all verifiable profiles- Auto-creates an empty profile row on
/auth/register(same DB transaction) - Admin CRUD:
GET /admin/users(filters: role, status, q, verification_status, paginated),GET/PATCH/DELETE /admin/users/{uuid},POST .../suspend|activate|approve-profile|reject-profile - Admins cannot suspend or delete other admins via these endpoints
- Self-service:
PATCH /me,GET /me/profile,PATCH /me/profile(role-aware validation; cannot self-set verification_status; resubmitting a rejected profile resets it to pending)
- Module 4: Household Registration — complete
households(uuid, head_user_id, barangay_id, coordinates POINT 4326, address_line, household_size, proof_of_residency_path, verification fields)household_memberspivot (household_id, user_id, relationship enum, full_name, date_of_birth, joined_at) — supports non-user members- Resident endpoints:
POST /households(one per head, auto-resolves barangay from GPS),GET /households/{uuid},PATCH /households/{uuid},POST .../proof(file upload),POST/DELETE .../members - Admin endpoints:
GET /admin/households(filters: status, barangay, q),GET .../{uuid},POST .../approve(requires proof on file, firesHouseholdVerifiedevent),POST .../reject(with reason) HouseholdVerifiedevent registered with placeholder listenerAllocateFreeQrCodesOnHouseholdVerified— Module 7 will replace the body with actual QR batch allocation- Approved households are immutable to residents (must contact admin)
- Resubmit-after-rejection auto-resets to
pending
- Module 5: Drop-off Points — complete
drop_off_points(uuid, name, code, barangay_id, coordinates POINT 4326 withSPATIAL INDEX, capacity_kg, operating_hours JSON, accepted_waste_types JSON, status, photo, contacts)drop_off_capacity_logs(fill_percent + recorded_by + notes)- Added
assigned_drop_off_point_idFK tohouseholds - Public endpoints:
GET /drop-off-points/nearby?lat&lng&radius_km(usesST_Distance_Sphere, returnsdistance_meters, sorts by distance, excludes inactive DOPs);GET /drop-off-points/{uuid} - Admin CRUD:
GET/POST/PATCH/DELETE /admin/drop-off-points,POST .../{uuid}/capacityto log a fill reading App\Services\DropOff\DropOffPointFinder::nearby()/nearest()— used by household auto-assign onPOST /households(setsassigned_drop_off_point_idto nearest active DOP within 25km)POST /households/{uuid}/reassign-drop-offre-runs the lookup- SampleDropOffPointsSeeder creates 5 DOPs around the sample barangays
- Module 6: Dumpsites — complete
dumpsites(uuid, name, code, city_municipality_id, coordinates POINT 4326 withSPATIAL INDEX, boundary_polygon POLYGON 4326, capacity_tons, operating_hours JSON, accepted_waste_types JSON, permit_number, contacts)dumpsite_releasesschema in place (trip_id is nullable bigint without FK; Module 10 will add the constraint whentripsexists)- Admin CRUD:
GET/POST/PATCH/DELETE /admin/dumpsites - Boundary input is
[{lat,lng}, ...](≥3 points); auto-closes the ring if the client doesn't repeat the first point Dumpsite::containsPoint(lat, lng)for geofence checks viaST_Contains— Module 10 firesarrived_at_dumpsitebased on this- SampleDumpsitesSeeder creates a sample Payatas-area dumpsite with a rectangular boundary so geofence tests + dev work
- Module 7: QR Code System — complete (CORE)
qr_code_batchesandqr_codestables;target_store_id/assigned_to_store_idare nullable bigints until Module 12 adds FKs- State machine via
spatie/laravel-model-states:unassigned → allocated → active → used, plusexpired/voidedside states. Classes underapp/States/QrCode/. - Serial format
PH-{area}-{YYMM}-{batch}-{code}-{checksum}viaQrSerialGenerator; 2-char SHA-256-derived alphanumeric checksum.isValid()rejects tampered serials. BatchGenerator::generate()— bulk-inserts in 1000-row chunks for big batches; per-day batch sequence; transactional.QrAllocator::allocateFreeToHousehold()allocates the configured quota (config('qr.free_allocation_per_household'), default 10) from a free batch — prefers a batch whose target_area covers the household's barangay, falls back to any active free batch.HouseholdVerifiedlistener now does real allocation. Don't register listeners explicitly inAppServiceProvider::bootif they follow theListener@handle(Event)convention — Laravel 11 auto-discovers them, and double-registering causes double-dispatch.QrBalanceLowevent fires fromQrAllocator::notifyBalanceIfLow()(Module 11 will hit this from the scan flow).- Admin endpoints:
GET/POST /admin/qr-batches,GET .../{batch},POST .../{batch}/mark-printed,GET .../{batch}/print.pdf,GET /admin/qr-codes/{serial}(lifecycle search),POST /admin/ qr-codes/{serial}/void(with reason). - Resident endpoints:
GET /me/qr-codes,GET /me/qr-codes/balance(counts by status + low_balance flag),POST /me/qr-codes/{serial}/ activate. - PDF:
barryvdh/laravel-dompdf+endroid/qr-code(v6 — usenew Builder(writer:, data:, ...)constructor; staticBuilder:: create()was removed) +picqer/php-barcode-generator. Blade template atresources/views/pdf/qr-batch.blade.phprenders 24 cards/A4 (4×6, QR + Code-128 + serial). config/qr.phpwithfree_allocation_per_householdandlow_balance_thresholdenv-overridable.
- Module 8: Routes — complete
routes(uuid, name, code, area_id, default_dumpsite_id, default_team_id nullable bigint until Module 9, estimated_duration_minutes, total_distance_km, status)route_stopspivot (route_id, drop_off_point_id, sequence, estimated_duration_at_stop_minutes;UNIQUE(route_id, sequence))- Admin CRUD:
GET/POST/PATCH/DELETE /admin/routes,POST .../{uuid}/clone(clones to inactive with-COPY-XXXXsuffix) - Stops are submitted as an ordered array; controller assigns sequence numbers from array index (drag-reorder is purely client-side)
App\Services\Route\RouteCalculatorrecomputestotal_distance_kmandestimated_duration_minutesafter every create/update/clone via MySQLST_Distance_Spherebetween consecutive stop coordinates + dumpsite, plus dwell time at stops, +20 min at dumpsite, divided byconfig('routes.avg_speed_kmh', 25.0)
- Module 9: Teams + Trucks — complete
trucks(with last_known_coordinates POINT 4326),collection_teams,team_members. Promotedroutes.default_team_idto a real FK.- Admin CRUD for trucks and teams;
TeamConflictDetectorflags double-assignment of driver/scanner/truck/active helper across teams;override_conflicts: truebypasses for emergency rotations.
- Module 10: Trips + Timeline — complete
trips,trip_stops,trip_timeline_events. Trip number autoTRIP-YYYYMMDD-NNN. Promoteddumpsite_releases.trip_idto FK.- Admin: schedule (clones route stops onto trip_stops), show, cancel.
- Driver:
start,arrive/depart/skipper stop,report-incident,arrive-dumpsite,release-load(creates DumpsiteRelease + tallies trip total_load_kg),complete. Each call writes a typed timeline event with GPS viaTripExecutor::log().
- Module 11: Scanning + Collection Logs — complete
collection_logs.ScanService::scan()validates: code state must beactive(used→duplicate, expired→expired, otherwise→invalid), GPS within 200m of DOP viaST_Distance_Sphere. On accept: transitions code toused, writes log, increments stop scan count, firesqr_scannedtimeline event, dispatchesQrBalanceLowif household active count drops below threshold.POST /scanner/scanandPOST /scanner/scan/bulk(offline sync; each scan processed independently).
- Module 12: Partner Stores — complete
partner_stores,store_inventories,store_purchases,store_sales. Promoted QR-code/batch FKs to partner_stores.StoreOperations::issueWholesale()generates a fresh batch targeted at a store, marks codesallocatedto that store, tops up inventory.StoreOperations::sellToHousehold()flips N codes from allocated→active reassigned to the household's id, computes commission at the store's rate, decrements inventory.
- Module 13: Reports + Analytics — complete (Reports/Analytics
sub-module of Module 13; Payments/Notifications/Live Tracking deferred)
daily_collection_stats,weekly_route_performance,monthly_store_salesaggregation tables.App\Services\Report\Aggregatorrebuilds each. Idempotent — deletes the slice and re-inserts.php artisan reports:aggregate --date=YYYY-MM-DDfor nightly job (default: yesterday).- Admin endpoints:
GET /admin/reports/{daily-collection, trip-performance,store-sales}chart-ready (series + totals),GET /admin/reports/compliance.csv?from=&to=streams a DENR-style dumpsite-release export,POST /admin/reports/rebuildtriggers aggregation on demand.
- Module 13b: Notifications — complete
notification_preferencestable +notifications(Laravel inbox).App\Notifications\Channels\SmsChanneladapts our SmsService.RoutesByPreferencestrait routes via database + sms based on per-user toggles. Notifications:HouseholdApproved,HouseholdRejected,QrBalanceLowNotification,CodesPurchased. Listeners auto-route via Laravel 11 discovery; rejection + purchase notifications dispatched directly from the controller /StoreOperations.GET/PATCH /me/notification-preferences. Defaults seeded on register.
- Module 13c: Payments — scaffolded (PayMongo + Manual)
paymentstable.PaymentDriverinterface withManualPaymentDriver(admin marks paid; default) andPayMongoDriver(activates whenPAYMONGO_SECRET_KEYis set; HMAC-SHA256 webhook verification).- Resident
POST /me/payments/code-purchase→ driver returns checkout URL. WebhookPOST /api/v1/webhooks/paymongoapplies paid/failed events. AdminPOST /admin/live/payments/{uuid}/mark-paidforces fulfillment for cash-paid or stuck payments. - Fulfillment runs
StoreOperations::sellToHousehold()— codes allocated → active for the household +CodesPurchasednotification.
- Module 13d: Live Tracking (HTTP polling) — complete
truck_location_history(POINT 4326 + SPATIAL INDEX).TruckTracker::record()writes history, updatestrucks.last_known_coordinates, Redis-caches latest position, flags geofence-trigger when entering the active trip's dumpsite.- Driver
POST /driver/trucks/{uuid}/location(15-second cadence; only the assigned-team driver may post). - Admin
GET /admin/live/trucksreturns positions for active trucks updated within the last hour. - Reverb broadcast deferred — polling is the data source for now.
- Flow corrections from this batch:
QrAllocator::allocateFreeToHousehold()is now idempotent — if the household already has any QR codes assigned, the listener skips. Re-approving a rejected→approved cycle no longer dispenses a second free batch.arrive-dumpsitevalidates the GPS is inside the dumpsite boundary viaDumpsite::containsPoint.override_geofence: truebypasses for known-bad GPS — admin can audit later.
- All 171 feature tests passing
- Deferred sub-modules: PayMongo payments, FCM notifications, Reverb live tracking
Geo notes
- Boundary polygons + centroids stored nullable for now. Once a full PSGC
dataset is loaded, add a follow-up migration that makes
boundaryNOT NULL and createsSPATIAL INDEX(boundary)(MySQL requires NOT NULL for spatial indexes). ST_Contains works without the spatial index, just slower. - For NCR (no provinces in PSGC), the 4 NCR districts are modeled as virtual provinces under the NCR region. This keeps the 4-level hierarchy uniform.
Default Admin (after db:seed)
- email:
admin@verde.local - password:
password - phone:
+639000000000