---
title: "Service Layer"
intent: "Guidance for creating service classes and performance best practices"
tags: ["service","performance","architecture"]
prereqs: ["php-best-practices.md","database-operations.md"]
complexity: "intermediate"
---

# Skill: Service Layer Development

## Context
This skill covers creating and using service classes for business logic in ChurchCRM.

## Service Layer First Principle

**When implementing business logic, ALWAYS create/update Service classes** in `src/ChurchCRM/Service/`

- Service methods encapsulate domain logic, database operations, and validation
- Call services from legacy pages (`src/*.php`), APIs, and modern routes
- Services use Propel ORM exclusively - **no RunQuery() or direct SQL**

## Key Services

Located in `src/ChurchCRM/Service/`:

- **PersonService** - Person/family operations
- **GroupService** - Group management
- **FinancialService** - Payments, pledges, funds
- **DepositService** - Deposit slip handling
- **SystemService** - System-wide operations
- **UserService** - User management with optimized database operations

## Example Usage

```php
// In API route or legacy page
$service = $container->get('FinancialService');
$result = $service->addPayment($fam_id, $method, $amount, $date, $funds);
return $response->withJson(['data' => $result]);
```

## Service Layer Performance Best Practices

When creating services, optimize database operations:

### 1. Selective field loading

Use `->select(['field1', 'field2'])` to fetch only required columns:

```php
// GOOD - Only fetch needed fields
$users = UserQuery::create()
    ->select(['Id', 'FailedLogins', 'TwoFactorAuthSecret'])
    ->find();

// BAD - Fetches all columns unnecessarily
$users = UserQuery::create()->find();
```

### 2. Single query philosophy

Group related data retrieval in one query, then process in PHP memory:

```php
// GOOD - One query, process in memory
$allUsers = UserQuery::create()
    ->select(['FailedLogins', 'TwoFactorAuthSecret'])
    ->find();

$lockedCount = 0;
$twoFactorCount = 0;
foreach ($allUsers as $user) {
    if ($user['FailedLogins'] >= 3) $lockedCount++;
    if (!empty($user['TwoFactorAuthSecret'])) $twoFactorCount++;
}

// BAD - Multiple queries
$lockedCount = UserQuery::create()->filterByFailedLogins(3, Criteria::GREATER_EQUAL)->count();
$twoFactorCount = UserQuery::create()->filterByTwoFactorAuthSecret(null, Criteria::ISNOTNULL)->count();
```

### 3. Avoid N+1 queries

Pre-fetch related data instead of looping with individual queries:

```php
// GOOD - Pre-fetch with join
$persons = PersonQuery::create()
    ->joinWith('Family')
    ->find();

foreach ($persons as $person) {
    echo $person->getFamily()->getName(); // No additional query
}

// BAD - N+1 queries (one per person)
$persons = PersonQuery::create()->find();
foreach ($persons as $person) {
    echo $person->getFamily()->getName(); // Additional query each iteration
}
```

### 4. Example: UserService getUserStats()

Fetches all users' `failedLogins` and `twoFactorAuthSecret` in one query, then processes statistics in memory:

```php
public function getUserStats(): array
{
    $users = UserQuery::create()
        ->select(['FailedLogins', 'TwoFactorAuthSecret'])
        ->find();
    
    $stats = [
        'total' => count($users),
        'locked' => 0,
        'twoFactorEnabled' => 0,
    ];
    
    foreach ($users as $user) {
        if ($user['FailedLogins'] >= SystemConfig::getValue('iMaxFailedLogins')) {
            $stats['locked']++;
        }
        if (!empty($user['TwoFactorAuthSecret'])) {
            $stats['twoFactorEnabled']++;
        }
    }
    
    return $stats;
}
```

## Creating API Endpoints vs Service Calls

**Only create API endpoints when:**
- Needed by external clients
- Required for AJAX operations
- Shared across multiple pages

**Do NOT create API endpoints** if a service method is only called from a legacy page:

```php
// GOOD - Call service directly from legacy page
require 'Include/Config.php';
$service = $container->get('PersonService');
$result = $service->updatePerson($personId, $data);

// BAD - Creating unnecessary API endpoint just to call service once
// Don't create /api/person/update if only used by one page
```

## Logging in Services

**Always use LoggerUtils** for business logic operations:

```php
use ChurchCRM\Utils\LoggerUtils;

class MyService
{
    private $logger;
    
    public function __construct()
    {
        $this->logger = LoggerUtils::getAppLogger();
    }
    
    public function doSomething($param): void
    {
        $this->logger->debug('Operation starting', ['param' => $param]);
        
        try {
            // Business logic here
            $this->logger->info('Operation succeeded', ['result' => $value]);
        } catch (\Exception $e) {
            $this->logger->error('Operation failed', ['error' => $e->getMessage()]);
            throw $e;
        }
    }
}
```

**Log levels:**
- `debug` - Development info, detailed execution flow
- `info` - Business events, successful operations
- `warning` - Issues that don't stop execution
- `error` - Failures, exceptions

**Always include context** as second parameter array for meaningful logs.

## SystemConfig for UI Settings Panels

**For admin pages that display system settings:**

Call `SystemConfig::getSettingsConfig($settingKeys)` to get structured configuration for a settings panel.

**Service method example:**

```php
public function getUserSettingsConfig(): array 
{
    $userSettings = [
        'iSessionTimeout',
        'iMaxFailedLogins',
        'bEnableLostPassword'
    ];
    return SystemConfig::getSettingsConfig($userSettings);
}
```

**Returns array with:** `category`, `name`, `value`, `type`, `options` for each setting

**Frontend usage:** Render collapsed setting panels using this structured data

**Avoid:** Hardcoding settings or creating separate SystemConfig lookups - use the service method

## Files

**Services:** `src/ChurchCRM/Service/`
**Service Container:** `src/ChurchCRM/ServiceContainerBuilder.php`
**Logger:** `src/ChurchCRM/Utils/LoggerUtils.php`
**Config:** `src/ChurchCRM/dto/SystemConfig.php`

## Auto-Triggering Service Methods from Model Saves <!-- learned: 2026-03-08 -->

For business logic that should automatically trigger when a model is saved (e.g., auto-geocoding on address change),
hook into the save handler in the page that modifies the model. This keeps services clean and prevents duplicate logic.

**Pattern:**
1. Service method checks if action is needed (e.g., address was modified but no coordinates exist)
2. Page/handler checks column modifications using `$model->isColumnModified(TableMap::COL_NAME)`
3. Instantiate and call service after model is saved

**Example: Auto-Geocode Family on Address Save**

```php
// Service: FamilyService->autoGeocodeFamily()
public function autoGeocodeFamily(Family $family): bool
{
    $street = trim($family->getAddress1() ?? '');
    if (empty($street)) {
        $this->logger->debug('autoGeocodeFamily: skipping empty address');
        return true;
    }

    try {
        $coords = GeoUtils::getLatLong(
            $street,
            $family->getCity(),
            $family->getState(),
            $family->getZip()
        );

        if ((float)$coords['Latitude'] === 0.0 && (float)$coords['Longitude'] === 0.0) {
            $this->logger->warning('autoGeocodeFamily: Could not geocode address');
            return false;
        }

        $family->setLatitude((float)$coords['Latitude']);
        $family->setLongitude((float)$coords['Longitude']);
        $family->save();

        $this->logger->info('autoGeocodeFamily: Geocoded successfully');
        return true;
    } catch (\Throwable $e) {
        $this->logger->warning('autoGeocodeFamily error: ' . $e->getMessage());
        return false;
    }
}

// Handler: FamilyEditor.php
use ChurchCRM\Service\FamilyService;

$family->save();
$family->reload();

// Auto-geocode if address was modified but no coordinates exist
if (
    ($family->isColumnModified(FamilyTableMap::COL_FAM_ADDRESS1)
        || $family->isColumnModified(FamilyTableMap::COL_FAM_CITY)
        || $family->isColumnModified(FamilyTableMap::COL_FAM_STATE)
        || $family->isColumnModified(FamilyTableMap::COL_FAM_ZIP))
    && empty($family->getLatitude())
) {
    $familyService = new FamilyService();
    $familyService->autoGeocodeFamily($family);
}
```

**Guidelines:**
- Service method should be idempotent (safe to call multiple times)
- Always log what happened (success/failure/skipped)
- Don't break the main transaction if the side-effect fails
- Use logger->debug for parameter values, ->info for success, ->warning for expected failures

## Time String Concatenation Hazards <!-- learned: 2026-04-09 -->

When building DB datetime strings by concatenating a date with a user-supplied time, **never blindly append `:00`** to add seconds — the input may already include seconds. `EventService::createRepeatEvents()` was producing invalid timestamps like `2026-04-09 09:00:00:00` when the form passed `09:00:00` instead of `09:00`.

Normalize the input to a known shape before concatenation:

```php
private static function normalizeTime(string $time): string
{
    $parsed = \DateTimeImmutable::createFromFormat('H:i:s', $time)
        ?: \DateTimeImmutable::createFromFormat('H:i', $time);
    if ($parsed === false) {
        return '09:00:00';
    }
    return $parsed->format('H:i:s');
}

// Caller — never ' . $time . ':00'
$startTime = self::normalizeTime($data['startTime'] ?? '09:00');
$eventStart = $occurrenceDate->format('Y-m-d') . ' ' . $startTime;
```

## Wrap Naked `new \DateTime($input)` in try/catch <!-- learned: 2026-04-09 -->

`new \DateTime($userString)` throws when the string is malformed, and the uncaught exception bubbles to a 500 instead of a proper 400. In a service method that throws `\InvalidArgumentException` for validation failures, wrap raw `DateTime` construction so the API layer gets a clean validation error:

```php
try {
    $rangeStart = new \DateTime($data['rangeStart'] ?? '');
    $rangeEnd = new \DateTime($data['rangeEnd'] ?? '');
} catch (\Throwable $e) {
    throw new \InvalidArgumentException(gettext('Invalid date format for rangeStart or rangeEnd'));
}
```

For stricter validation at the route level (before reaching the service), use `\DateTimeImmutable::createFromFormat('!Y-m-d', $input)` and check the round-trip — `DateTime` constructor accepts loose formats like `2026-13-99` and rolls them over silently.

## Cap Bulk-Generation Loops <!-- learned: 2026-04-09 -->

Any service method that generates rows from a date range needs an upper bound to stop a wide range from creating thousands of rows in one request. `createRepeatEvents()` could otherwise generate `weekly × 10 years × 52 = 520` rows per call.

```php
public const MAX_REPEAT_OCCURRENCES = 366;

if (count($occurrenceDates) > self::MAX_REPEAT_OCCURRENCES) {
    throw new \InvalidArgumentException(sprintf(
        gettext('Too many occurrences (%d) — narrow the date range. Maximum allowed: %d'),
        count($occurrenceDates),
        self::MAX_REPEAT_OCCURRENCES
    ));
}
```
