Volunteering Module Guide¶
Last reviewed: 2026-06-23
How-to / reference guide for the Volunteering module: organisations, opportunities, shifts, hour logging, and the time-credit mint on approval. Verified against the live service layer (app/Services/Volunteer*, app/Services/Shift*, app/Services/VolOrgWalletService.php) and routes/api.php.
This guide replaces an archived engine-report snapshot that documented an older "auto-pay" behaviour. The current model is auto-mint on approval — see Hour logging and the credit mint. Do not treat the archived report as live.
Audience and supported workflows¶
The module serves two distinct roles — the "two hats" model. A single user can wear either or both hats.
| Hat | Who | Supported workflows |
|---|---|---|
| Volunteer | Any member | Browse opportunities, apply, sign up for shifts, log hours, join shift waitlists, request shift swaps, QR check-in, earn an impact certificate, review organisations. |
| Organisation | A member who registers/owns/admins a vol_organization |
Post opportunities, review applications, approve/decline logged hours (which mints credits), manage the organisation wallet, manage shifts, view volunteers and stats. |
Platform/tenant admins have a superset of both via the /v2/admin/volunteering/* surface (AdminVolunteerController, VolunteerCommunityController, VolunteerWellbeingController, VolunteerExpenseController).
The two hats are enforced in code: an organisation starts at status = 'pending' after registration (VolunteerService::createOrganization) and only appears in the public directory once a tenant admin approves it to active/approved (getOrganisations filters on whereIn('status', ['approved', 'active'])). Until then the owner sees a pending-approval state rather than a live org.
Tenant scoping and feature gate¶
- Feature gate: every member-facing endpoint calls
VolunteerController::ensureFeature(), which throws403 FEATURE_DISABLEDwhenTenantContext::hasFeature('volunteering')is false.volunteeringdefaults to on via theFEATURE_DEFAULTSconstant inapp/Services/TenantFeatureConfig.php(resolved throughTenantContext), so it is enabled unless a tenant turns it off. - Tenant scoping: every query is scoped by
TenantContext::getId()— either through theHasTenantScopetrait on the Eloquent models (VolOpportunity,VolApplication,VolLog,VolShift,VolOrganization) or explicitAND tenant_id = ?clauses in the raw-SQL paths. Cross-tenant joins are pinned withwhereColumn('o.tenant_id', 'a.tenant_id')style predicates so a join can never bridge two tenants. - Caring Community interplay: member self-logging can be disabled per tenant via
CaringCommunityWorkflowPolicyService. Whenallow_member_self_logis false,logHours()returns403unless the caller can bypass the policy (admin role orvolunteering.hours.reviewpermission).
Key code locations¶
| Concern | Code |
|---|---|
| HTTP entry (member) | app/Http/Controllers/Api/VolunteerController.php |
| HTTP entry (admin) | AdminVolunteerController, VolunteerCommunityController, VolunteerWellbeingController, VolunteerExpenseController, VolunteerCheckInController, VolunteerCertificateController |
| Core service | app/Services/VolunteerService.php (opportunities, applications, shifts, hour log + verify/mint, organisations, reviews) |
| Org wallet | app/Services/VolOrgWalletService.php |
| Certificates | app/Services/VolunteerCertificateService.php |
| QR check-in | app/Services/VolunteerCheckInService.php |
| Matching / recommendations | app/Services/VolunteerMatchingService.php |
| Shift waitlist | app/Services/ShiftWaitlistService.php |
| Shift swaps | app/Services/ShiftSwapService.php |
| Per-tenant config | app/Services/VolunteeringConfigurationService.php |
| Safeguarding | app/Services/SafeguardingService.php, SafeguardingTriggerService, SafeguardingPreferenceService |
| Vol-log status events | app/Providers/EventServiceProvider.php → App\Events\VolLogStatusChanged |
| Listeners | AwardXpOnVolLogApproved, RevertRegionalPointsOnVolLogChange, PostFeedActivityOnVolLogApproved |
Tables: vol_organizations, vol_opportunities, vol_applications, vol_shifts, vol_logs, vol_org_transactions, vol_certificates, vol_shift_checkins, vol_shift_waitlist, vol_shift_swap_requests, vol_reviews. Membership/roles are stored in org_members with org_type = 'volunteer'. Minted credits also write the platform-wide transactions table and adjust users.balance.
Frontend entry points: React react-frontend/src/pages (volunteering pages and org dashboard) and the accessible GOV.UK track under /{tenantSlug}/alpha/... (app/Http/Controllers/GovukAlpha, VolunteeringParityTest and friends).
Routes / API contract: member routes are /v2/volunteering/* and admin routes /v2/admin/volunteering/* in routes/api.php (≈ lines 774–816 and 2052–2106). Refer to that file and the OpenAPI surface rather than copying the endpoint table here. Note opportunities, showOpportunity, organisations, and showOrganisation are explicitly public (withoutMiddleware('auth:sanctum')); everything else requires auth.
Hour logging and the credit mint¶
This is the core invariant of the module. Approving logged hours always mints time credits to the volunteer — classic timebanking, not a transfer gated by any balance.
- Log —
VolunteerService::logHours()inserts avol_logsrow. The member must have a real relationship with the organisation (an approved application, or owner/admin membership). Default status ispending; the Caring Community policy can auto-approve. - Per-entry ceiling:
volunteering.max_hours_per_shift(admin-configurable, absolute hard cap of 24h). - Future dates and duplicate (
org+date+opportunity) entries are rejected; concurrent duplicate submits are serialised with a cache lock. - Verify — an org owner/admin (never the volunteer themselves) calls
VolunteerService::verifyHours($logId, $adminUserId, 'approve'|'decline'). Approval runs inside a single DB transaction: - The status flip is conditional (
... WHERE status = 'pending'). A concurrent/retried approval finds 0 rows affected and returnsalready_processedwithout re-minting — the idempotency gate. - The mint always runs on approval. Whole hours (
floor(hours)) are added tousers.balance; the same integer is debited fromvol_organizations.balance, which is allowed to go negative. The org wallet is a reconciliation figure, not a spending limit. There is noinsufficient_balancerejection on this path and no dependency on theauto_pay_enabledflag. - A second idempotency guard checks
vol_org_transactionsfor an existingvolunteer_paymentfor thatvol_log_id(returnsalready_paid). - If the log floors to zero whole hours, no credits move and the outcome is
no_whole_hours. Fractional remainders stay in the org wallet. - Rows are written to
vol_org_transactions(audit,balance_aftermay be negative) and the platformtransactionstable (transaction_type = 'volunteer'). - Payment outcomes are exposed via
VolunteerService::getLastPaymentOutcome():paid|no_whole_hours|already_paid|already_processed|null(declined).
Removed from the standard approval (
verifyHours) path in the June-2026 overhaul: the old per-org "auto-pay" dependency and theinsufficient_balancetrap that used to block credit when the org wallet was empty. The trap was not deleted from the codebase — the legacyapplyVolunteerAutoPayment()helper still contains it and is still reached on the self-log auto-approve path whenorg.auto_pay_enabledis set. The standard approval flow inverifyHours()mints unconditionally.
Side-effects on pending → approved¶
verifyHours() dispatches VolLogStatusChanged, wired to three listeners in EventServiceProvider:
| Listener | Effect | Idempotency |
|---|---|---|
AwardXpOnVolLogApproved |
+20 XP per hour (GamificationService::XP_VALUES['volunteer_hour']); re-checks vol_1h..vol_500h badges. |
Encodes [vol_log:N] in the XP description and skips if already present. |
PostFeedActivityOnVolLogApproved |
Posts a volunteer feed-activity row. |
INSERT ... ON DUPLICATE KEY UPDATE on (tenant_id, source_type, source_id). |
RevertRegionalPointsOnVolLogChange |
Only fires when a log leaves approved (e.g. approved→declined); reverses any Caring Community regional points. No-op on the mint path. |
Guards on previousStatus === 'approved'. |
All three pin tenant context with TenantContext::setById() / restoreAfterScopedListener() and swallow their own exceptions so a side-effect failure can never roll back the committed approval.
Shifts: waitlists, swaps, and check-in¶
- Shifts (
vol_shifts) hang off an opportunity, with optionalcapacity. A volunteer signs up only after their application is approved. Capacity is re-checked underFOR UPDATErow locks (signUpForShift,handleApplicationviashiftHasApprovalCapacity) so concurrent signups cannot overbook. - Waitlist (
ShiftWaitlistService,vol_shift_waitlist): join/leave with locked position re-ordering. When a slot frees (cancellation, declined approval, swap displacement),notifyNext()marks the nextwaitingentrynotifiedand notifies them in their own locale; they claim viapromoteUser()(re-checks capacity under lock). Stale offers are expired and cascaded byexpireStaleNotifications()(cron). - Swaps (
ShiftSwapService,vol_shift_swap_requests): a signed-up volunteer proposes a swap to another signed-up volunteer. Optional admin approval is gated byvolunteering.swap_requires_admin.executeSwap()runs under locks, refuses started shifts, and blocks double-booking viahasOverlappingShift(). - QR check-in (
VolunteerCheckInService,vol_shift_checkins): per shift+volunteer token (bin2hex(random_bytes(32))), only issued to approved volunteers. Scanning verifies check-in (allowing 30 min early); coordinators view check-ins via the admin dashboard.getShiftCheckIns()deliberately omitsqr_token.
The organisation wallet¶
VolOrgWalletService manages each vol_organization's time-credit balance.
- All balance mutations use
DB::transaction()+FOR UPDATElocks and write avol_org_transactionsrow withbalance_afterfor audit integrity. - Lock order is fixed (user → org) across
depositFromUser()andpayVolunteer()to avoid deadlock when a deposit and payout race. - Operations:
depositFromUser(member tops up the org from personal credits, capped at 1000),payVolunteer(manual payout; pays only the integer floor and retains the fractional remainder),adminAdjustment(admin top-up/deduct; refuses to push the balance below zero). - Whole-hour invariant:
users.balancestores whole hours, so every cross-account move floors to an integer and debits/credits the same integer — no phantom credits are created or destroyed. - The mint inside
verifyHours()debits the org wallet directly (allowing negative balances), independently ofpayVolunteer().
Certificates¶
VolunteerCertificateService::generate() sums a volunteer's approved hours (optionally filtered by org/date), writes a vol_certificates row with a 12-char verification_code, and emails the volunteer in their locale. verify($code) is the public verification path (tenant-scoped when a tenant context is present); generateHtml() renders a printable certificate. Optional minimum-hours gate: volunteering.min_hours_for_certificate.
Safeguarding¶
Safeguarding is a cross-cutting concern, not a sub-feature of hour logging. SafeguardingService manages guardian↔ward assignments (with an active-user, no-self-assignment guard), safeguarding training records, and incident reporting with a constrained status-transition map. volunteering.guardian_consent_required gates guardian-consent flows per tenant. Admin/broker surfaces live under /v2/admin/safeguarding/* and /v2/admin/caring-community/safeguarding/*. Message-level safeguarding flags flow through SafeguardingFlaggedEvent → NotifySafeguardingStaff.
Per-tenant configuration¶
VolunteeringConfigurationService stores volunteering.* keys in tenant_settings (5-minute cache). It controls 17 tab-visibility toggles plus behavioural settings used across the services above: max_hours_per_shift, cancellation_deadline_hours, min_hours_for_certificate, swap_requires_admin, auto_approve_applications, guardian_consent_required, enable_qr_checkin, enable_matching, and others. Read DEFAULTS in that file for the authoritative list and default values.
Security and privacy invariants¶
- Volunteers cannot verify/approve their own logged hours (
verifyHoursself-check). - Opportunity creators cannot apply to their own opportunity (
applyself-check). is_owneron an opportunity means creator only — org owners/admins use the broadercan_managegrant; admin tooling must not surface owner-only UI on other members' posts.- QR tokens are never returned in shift roster listings.
- All notifications/emails render in the recipient's
preferred_languageviaLocaleContext::withLocale()(admin fan-outs wrap per-recipient inside the loop). - Hour-log credit movement conserves credits: same integer debited from org and credited to volunteer; fractional remainders never vanish.
Failure modes and recovery¶
| Symptom | Likely cause | Recovery |
|---|---|---|
Approving hours mints nothing (no_whole_hours) |
Log floored to 0 whole hours (e.g. 0.5h entry). | Expected. Sub-hour logs accumulate value in the org wallet; the volunteer is credited once whole hours are reached. |
| Hours approved but no second credit on retry | Idempotency gate (already_processed / already_paid). |
Expected and correct — no action. |
| Org wallet shows a negative balance | The mint debits the org unconditionally on approval. | Expected by design; reconcile via depositFromUser or adminAdjustment. |
| New organisation not in the public directory | Status is pending awaiting tenant-admin approval. |
Approve it (/v2/admin/volunteering/organizations/{id}/status). |
| Volunteer cannot log hours | Caring Community allow_member_self_log is off, or no approved application / org membership. |
Grant membership/approve application, or adjust the tenant policy. |
| Waitlist spot offer never claimed | notified offer expired. |
expireStaleNotifications() (cron) cascades the offer to the next person automatically. |
All endpoints return 403 FEATURE_DISABLED |
volunteering feature off for the tenant. |
Re-enable the feature in tenant settings. |
| XP/feed activity missing after approval | Listener swallowed an exception (logged as warning), or already idempotently applied. | Check logs for AwardXpOnVolLogApproved / PostFeedActivityOnVolLogApproved. The mint itself is unaffected. |
Tests and verification¶
Run the volunteering suites (sequentially — never run multiple heavy suites at once):
vendor/bin/phpunit --filter Volunteer --colors=always
vendor/bin/phpunit --filter 'Shift(Waitlist|Swap)' --colors=always
vendor/bin/phpunit tests/Laravel/Feature/Volunteering --colors=always
Important regression tests:
tests/Laravel/Unit/Services/VolunteerServiceTest.php— core hour-log, approval, and mint behaviour.tests/Laravel/Unit/Services/VolunteerFlowIntegrationTest.php— end-to-end apply → approve → log → verify → mint.tests/Laravel/Unit/Services/VolunteerSecurityRegressionTest.php— self-approval, ownership, and tenant-scoping guards.tests/Laravel/Feature/Volunteering/VolunteerApplyDuplicateGuardTest.php— concurrent duplicate-apply lock.tests/Laravel/Feature/Volunteering/VolunteeringAuditFixesTest.php— fixes from the 2026-06 audit (verify-hours race, etc.).tests/Laravel/Unit/Services/ShiftWaitlistServiceTest.php,ShiftSwapServiceTest.php— slot-freeing, promotion, double-booking guards.tests/Laravel/Unit/Services/VolunteerCertificateServiceTest.php,VolunteerCheckInServiceTest.php,VolunteerMatchingServiceTest.php,VolunteeringConfigurationServiceTest.php.tests/Laravel/Integration/VolunteerEmailReliabilityTest.php— recipient-locale notification rendering.
After any schema change, run PHPStan and refresh the schema dump per the project deploy rules.