Skip to content

Locality & Context Management

Locality middleware manages the current execution context - identifying which advisor, household, organization, or federation the request is operating within. This is critical for multi-tenant isolation and authorization.

ComponentLocationPurpose
InitializeLocalityapp/Http/Middleware/Extract locality from route
CheckLocalityConsistentWithLoginUserapp/Http/Middleware/Validate login user matches locality
LocalityManagerapp/Locality/Services/Manage locality state (facade + service)
Localityapp/Locality/Abstract base class
AdvisorLocalityapp/Locality/Advisor context
HouseholdLocalityapp/Locality/Household context (extends ImpersonatableAdvisorLocality)
OrganizationLocalityapp/Locality/Organization context
FederationLocalityapp/Locality/Federation context
UserLocalityapp/Locality/User context
ImpersonatableAdvisorLocalityapp/Locality/Base for impersonatable contexts
AddXRightCapitalUserInfoHeaderapp/Http/Middleware/Add user info to response
AddXRightCapitalCalculationIdapp/Http/Middleware/Add calculation ID to response

File: app/Http/Middleware/InitializeLocality.php

Extracts locality information from route and initializes the context via LocalityManager.

final class InitializeLocality
{
public function handle(Request $request, Closure $next): Response
{
$route = $request->route();
if ($route !== null) {
try {
LocalityManager::initializeLocalityFromRoute($route);
} catch (ModelNotFoundException $e) {
throw new NotFoundHttpException(
'The ' . Str::snake(class_basename($e->getModel()), ' ') . ' cannot be found.',
$e
);
}
}
return $next($request);
}
}
  • Passes the entire Route object to LocalityManager::initializeLocalityFromRoute()
  • Catches ModelNotFoundException and converts to 404 with user-friendly message
  • Uses Str::snake(class_basename()) to format model name in error
{
"message": "The household cannot be found."
}

HTTP Status: 404 Not Found


File: app/Locality/Services/LocalityManager.php (accessed via facade)

Central manager for locality state throughout the request lifecycle. Uses encrypted IDs from route parameters.

app/Facades/LocalityManager.php
final class LocalityManager extends Facade
{
protected static function getFacadeAccessor(): string
{
return LocalityManagerService::class;
}
}
final class LocalityManager
{
private ?Locality $locality = null;
private bool $ignoreLicenseStatus = false;
public function initializeLocalityFromRoute(Route $route): void
{
// Priority order for locality detection
$locality_map = [
'household' => fn(int $id) => new HouseholdLocality(Household::findOrFail($id)),
'advisor' => fn(int $id) => new AdvisorLocality(Advisor::findOrFail($id)),
'user' => fn(int $id) => new UserLocality(User::findOrFail($id)),
'organization' => fn(int $id) => new OrganizationLocality(Organization::findOrFail($id)),
'federation' => fn(int $id) => new FederationLocality(Federation::findOrFail($id)),
];
foreach ($locality_map as $param => $factory) {
$encrypted_id = $route->parameter($param);
if ($encrypted_id !== null) {
// Decrypt the ID from route parameter
$id = Crypt::decryptId($encrypted_id);
$this->locality = $factory($id);
return;
}
}
}
public function locality(): ?Locality
{
return $this->locality;
}
public function switchLocality(Locality $locality): void
{
$this->locality = $locality;
}
public function switchHousehold(Household $household): void
{
$this->locality?->switchHousehold($household);
}
public static function switchIgnoreLicenseStatus(bool $ignore, Closure $callback): mixed
{
// Temporarily switch license status check
$previous = app(self::class)->ignoreLicenseStatus;
app(self::class)->ignoreLicenseStatus = $ignore;
try {
return $callback();
} finally {
app(self::class)->ignoreLicenseStatus = $previous;
}
}
}
  • Encrypted IDs: Route parameters are encrypted, decrypted via Crypt::decryptId()
  • Priority order: household → advisor → user → organization → federation
  • Facade access: Use LocalityManager::locality() etc.
  • License status switching: switchIgnoreLicenseStatus() for temporary bypass

File: app/Http/Middleware/CheckLocalityConsistentWithLoginUser.php

Validates that the login user has access to the current locality context.

final class CheckLocalityConsistentWithLoginUser
{
public function handle(Request $request, Closure $next): Response
{
$locality = LocalityManager::locality();
if ($locality === null) {
return $next($request);
}
$login_user = Auth::getUserOrFail();
// Different validation based on locality type
if ($locality instanceof HouseholdLocality) {
$this->validateHouseholdAccess($login_user, $locality);
} elseif ($locality instanceof AdvisorLocality) {
$this->validateAdvisorAccess($login_user, $locality);
} elseif ($locality instanceof OrganizationLocality) {
$this->validateOrganizationAccess($login_user, $locality);
}
return $next($request);
}
private function validateHouseholdAccess(User $user, HouseholdLocality $locality): void
{
// Validate user can access this household
if ($user->type === UserType::CLIENT) {
// Client must belong to this household
if ($user->household_id !== $locality->getHousehold()->id) {
throw new AccessDeniedHttpException('You cannot access this household.');
}
} else {
// Advisor must own or have access to this household
$household = $locality->getHousehold();
if ($user->advisor_id !== $household->advisor_id) {
throw new AccessDeniedHttpException('You cannot access this household.');
}
}
}
}
  • Validates based on locality type (Household, Advisor, Organization)
  • For clients: must belong to the household
  • For advisors: must own or have access to the resource
  • Throws AccessDeniedHttpException on mismatch

File: app/Locality/Locality.php

Base abstract class for all locality types.

abstract class Locality
{
abstract public function getAdvisor(): ?Advisor;
abstract public function getAdvisorOrFail(): Advisor;
abstract public function getAdvisorId(): ?int;
abstract public function switchHousehold(Household $household): void;
}
MethodPurpose
getAdvisor()Get advisor from locality (nullable)
getAdvisorOrFail()Get advisor or throw exception
getAdvisorId()Get advisor ID (nullable)
switchHousehold()Switch to a different household within locality

Base class for localities that support impersonation:

abstract class ImpersonatableAdvisorLocality extends Locality
{
protected Advisor $advisor;
protected ?Household $household = null;
public function getAdvisor(): Advisor
{
return $this->advisor;
}
public function getAdvisorOrFail(): Advisor
{
return $this->advisor;
}
public function getAdvisorId(): int
{
return $this->advisor->id;
}
}
final class HouseholdLocality extends ImpersonatableAdvisorLocality
{
public function __construct(Household $household)
{
$this->household = $household;
$this->advisor = $household->advisor;
}
public function getHousehold(): Household
{
return $this->household;
}
public function switchHousehold(Household $household): void
{
$this->household = $household;
}
public function cacheKey(): string
{
return "household:{$this->household->id}";
}
}
final class AdvisorLocality extends ImpersonatableAdvisorLocality
{
public function __construct(Advisor $advisor)
{
$this->advisor = $advisor;
}
public function switchHousehold(Household $household): void
{
$this->household = $household;
}
public function cacheKey(): string
{
return "advisor:{$this->advisor->id}";
}
}
final class OrganizationLocality extends Locality
{
public function __construct(
private readonly Organization $organization
) {}
public function getOrganization(): Organization
{
return $this->organization;
}
public function getAdvisor(): ?Advisor
{
return null;
}
public function getAdvisorOrFail(): Advisor
{
throw new RuntimeException('Organization locality does not have an advisor.');
}
public function getAdvisorId(): ?int
{
return null;
}
public function switchHousehold(Household $household): void
{
throw new RuntimeException('Cannot switch household in organization locality.');
}
}
final class FederationLocality extends Locality
{
public function __construct(
private readonly Federation $federation
) {}
public function getFederation(): Federation
{
return $this->federation;
}
public function getAdvisor(): ?Advisor
{
return null;
}
public function getAdvisorOrFail(): Advisor
{
throw new RuntimeException('Federation locality does not have an advisor.');
}
public function getAdvisorId(): ?int
{
return null;
}
public function switchHousehold(Household $household): void
{
throw new RuntimeException('Cannot switch household in federation locality.');
}
}
final class UserLocality extends Locality
{
public function __construct(
private readonly User $user
) {}
public function getUser(): User
{
return $this->user;
}
public function getAdvisor(): ?Advisor
{
return $this->user->advisor;
}
public function getAdvisorOrFail(): Advisor
{
return $this->user->advisor ?? throw new RuntimeException('User does not have an advisor.');
}
public function getAdvisorId(): ?int
{
return $this->user->advisor_id;
}
public function switchHousehold(Household $household): void
{
// User locality may support household switching
}
}

File: app/Http/Middleware/AddXRightCapitalUserInfoHeader.php

Adds encrypted user ID and impersonator info to response headers for access logging.

final class AddXRightCapitalUserInfoHeader
{
public const string HEADER_X_RIGHTCAPITAL_USERID = 'X-RightCapital-UserID';
public const string HEADER_X_RIGHTCAPITAL_EMPLOYEE_EMAIL = 'X-RightCapital-Employee-Email';
public function handle(Request $request, Closure $next): SymfonyResponse
{
$response = $next($request);
$login_user_id = Auth::id();
if ($login_user_id !== null && ($response instanceof Response || $response instanceof JsonResponse)) {
// Encrypt user ID in header
$response->withHeaders([
self::HEADER_X_RIGHTCAPITAL_USERID => Crypt::encryptId($login_user_id),
]);
// Add impersonator email from session
$employee = Session::get(SessionEntity::KEY_IMPERSONATOR_EMPLOYEE);
if ($employee !== null) {
$response->withHeaders([
self::HEADER_X_RIGHTCAPITAL_EMPLOYEE_EMAIL => $employee['email'],
]);
}
}
return $response;
}
}
  • Encrypts user ID using Crypt::encryptId() (not plain ID)
  • Gets impersonator from session key SessionEntity::KEY_IMPERSONATOR_EMPLOYEE (not app('impersonator'))
  • Only adds headers to Response or JsonResponse instances
X-RightCapital-UserID: encrypted_id_string
X-RightCapital-Employee-Email: employee@rightcapital.com # If impersonating

File: app/Http/Middleware/AddXRightCapitalCalculationId.php

Adds calculation tracking IDs for debugging and correlation.

final class AddXRightCapitalCalculationId
{
public const string HEADER_CALCULATION_IDS = 'X-RightCapital-Calculation-IDs';
public function handle(Request $request, Closure $next): SymfonyResponse
{
$response = $next($request);
$calculation_ids = Context::getCalculationIds();
if (count($calculation_ids) > 0 && $response instanceof Response) {
$response->header(self::HEADER_CALCULATION_IDS, implode(',', $calculation_ids));
// Handle CORS expose headers
$expose_headers = $response->headers->get('Access-Control-Expose-Headers', '');
$expose_list = array_filter(explode(',', $expose_headers));
$expose_list[] = self::HEADER_CALCULATION_IDS;
$response->header('Access-Control-Expose-Headers', implode(',', array_unique($expose_list)));
}
return $response;
}
}
  • Uses Context::getCalculationIds() to get calculation IDs (not app('calculation.id'))
  • Supports multiple calculation IDs (comma-separated)
  • Merges CORS expose headers to ensure header is visible to browser
X-RightCapital-Calculation-IDs: calc_123,calc_456
Access-Control-Expose-Headers: X-RightCapital-Calculation-IDs,...

Route parameters containing IDs are encrypted for security:

Route::prefix('advisors/{advisor}')->group(function () {
Route::get('/', [AdvisorController::class, 'show']);
Route::prefix('households/{household}')->group(function () {
Route::get('/', [HouseholdController::class, 'show']);
});
});
/advisors/encrypted_advisor_id/households/encrypted_household_id
$encrypted_id = $route->parameter('household');
$id = Crypt::decryptId($encrypted_id);
$household = Household::findOrFail($id);

Federation
└── Organization
└── Advisor
└── Household

When a household locality is set:

  • LocalityManager::locality() → HouseholdLocality
  • $locality->getAdvisor() → Household’s Advisor
  • $locality->getHousehold() → Household

class HouseholdController extends Controller
{
public function show()
{
// Locality was set by middleware from route parameter
$locality = LocalityManager::locality();
// Type check
if ($locality instanceof HouseholdLocality) {
$household = $locality->getHousehold();
$advisor = $locality->getAdvisor();
}
return new HouseholdResource($household);
}
}
class PlanController extends Controller
{
public function compare(Household $other)
{
// Switch to compare with another household
LocalityManager::switchHousehold($other);
// Perform comparison...
}
}

ComponentPurpose
InitializeLocalityExtract locality from route, pass to LocalityManager
CheckLocalityConsistentWithLoginUserValidate login user can access locality
LocalityManagerFacade + service for locality state management
Locality (abstract)Base class with getAdvisor(), switchHousehold()
ImpersonatableAdvisorLocalityBase for localities with advisor context
HouseholdLocalityHousehold context (extends ImpersonatableAdvisorLocality)
AdvisorLocalityAdvisor context
OrganizationLocalityOrganization context (no advisor)
FederationLocalityFederation context (no advisor)
UserLocalityUser context
AddXRightCapitalUserInfoHeaderEncrypted user ID + impersonator from session
AddXRightCapitalCalculationIdCalculation IDs from Context + CORS headers