# Laravel and PHP

A complete setup for a Laravel admin panel: keep a local copy of every SentinelShare ban, refresh it every five minutes, and check players against it when they join. At the end there is a plain-PHP lookup if you don't use Laravel.

Works on Laravel 10 and 11 with MySQL, MariaDB or PostgreSQL. You need a community API key (see [Admin panel API](/developers/admin-panel-api#get-a-key)).

## 1. Configuration

Add the key to `.env` (never commit it):

```bash
SENTINEL_API_URL=https://sentinelshare.com/api/v1/integration
SENTINEL_API_KEY=ssk_your_key_here
```

And to `config/services.php`:

```php
'sentinelshare' => [
    'url' => env('SENTINEL_API_URL', 'https://sentinelshare.com/api/v1/integration'),
    'key' => env('SENTINEL_API_KEY'),
],
```

## 2. A table for network bans

`php artisan make:migration create_sentinel_bans_table`, then:

```php
<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    public function up(): void
    {
        Schema::create('sentinel_bans', function (Blueprint $table) {
            $table->uuid('id')->primary();                 // SentinelShare ban id
            $table->uuid('player_id')->index();
            $table->string('player_alias');
            $table->json('arma_uids');
            $table->json('discord_ids');
            $table->string('status', 16)->index();         // active | lifted
            $table->string('type', 32);                    // CHEATING, EXPLOITING, ...
            $table->string('verification_state', 32);      // UNVERIFIED, PEER_VERIFIED, ...
            $table->text('reason')->nullable();
            $table->string('filed_by')->nullable();
            $table->string('filed_by_community')->nullable();
            $table->timestamp('filed_at')->nullable();
            $table->timestamp('changed_at')->nullable();   // SentinelShare's updated_at
            $table->json('record');                        // the full record as received
            $table->timestamps();
        });
    }

    public function down(): void
    {
        Schema::dropIfExists('sentinel_bans');
    }
};
```

## 3. The model

`app/Models/SentinelBan.php`:

```php
<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;

class SentinelBan extends Model
{
    public $incrementing = false;
    protected $keyType = 'string';
    protected $guarded = [];

    protected $casts = [
        'arma_uids' => 'array',
        'discord_ids' => 'array',
        'record' => 'array',
        'filed_at' => 'datetime',
        'changed_at' => 'datetime',
    ];

    /** Columns for one ban record from the SentinelShare API. */
    public static function attributesFrom(array $r): array
    {
        return [
            'player_id' => $r['player']['id'],
            'player_alias' => $r['player']['alias'],
            'arma_uids' => $r['player']['arma_uids'],
            'discord_ids' => $r['player']['discord_ids'],
            'status' => $r['status'],
            'type' => $r['type'],
            'verification_state' => $r['verification']['state'],
            'reason' => $r['reason'],
            'filed_by' => $r['filed_by']['name'],
            'filed_by_community' => $r['filed_by']['community'],
            'filed_at' => $r['filed_at'],
            'changed_at' => $r['updated_at'],
            'record' => $r,
        ];
    }

    public function scopeActive(Builder $query): Builder
    {
        return $query->where('status', 'active');
    }

    public function scopeVerified(Builder $query): Builder
    {
        return $query->whereIn('verification_state', ['PEER_VERIFIED', 'STAFF_VERIFIED']);
    }

    /** SentinelShare stores Arma UIDs in lower case. */
    public function scopeForArmaUid(Builder $query, string $armaUid): Builder
    {
        return $query->whereJsonContains('arma_uids', strtolower(trim($armaUid)));
    }

    public function scopeForDiscordId(Builder $query, string $discordId): Builder
    {
        return $query->whereJsonContains('discord_ids', trim($discordId));
    }
}
```

## 4. The API client

`app/Services/SentinelShare.php`:

```php
<?php

namespace App\Services;

use Illuminate\Http\Client\PendingRequest;
use Illuminate\Support\Facades\Http;

class SentinelShare
{
    private function http(): PendingRequest
    {
        return Http::baseUrl(config('services.sentinelshare.url'))
            ->withToken(config('services.sentinelshare.key'))
            ->acceptJson()
            ->timeout(15)
            ->retry(3, 5000);   // covers brief outages and 429s
    }

    /** Bans created or changed since the cursor. */
    public function bans(?string $since = null, int $limit = 200): array
    {
        return $this->http()
            ->get('/bans', array_filter(['since' => $since, 'limit' => $limit]))
            ->throw()
            ->json();
    }

    /** One player and all their bans. Pass any of arma_uid, discord_id, ip, name. */
    public function lookup(array $identifiers): array
    {
        return $this->http()->get('/lookup', array_filter($identifiers))->throw()->json();
    }
}
```

## 5. The sync command

`php artisan make:command SyncSentinelBans`, then:

```php
<?php

namespace App\Console\Commands;

use App\Models\SentinelBan;
use App\Services\SentinelShare;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Cache;

class SyncSentinelBans extends Command
{
    protected $signature = 'sentinel:sync {--full : Ignore the saved cursor and sync everything}';
    protected $description = 'Pull new and changed bans from SentinelShare';

    public function handle(SentinelShare $sentinel): int
    {
        $cursor = $this->option('full') ? null : Cache::get('sentinelshare.cursor');
        $saved = 0;

        do {
            $page = $sentinel->bans($cursor);

            foreach ($page['items'] as $ban) {
                SentinelBan::updateOrCreate(['id' => $ban['id']], SentinelBan::attributesFrom($ban));
                $saved++;
            }

            $cursor = $page['next_cursor'];
            if ($cursor) {
                Cache::forever('sentinelshare.cursor', $cursor);
            }
        } while ($page['has_more']);

        $this->info("Saved {$saved} bans.");

        return self::SUCCESS;
    }
}
```

Run it once by hand: `php artisan sentinel:sync`. The first run pages through every ban on the network.

If your cache gets cleared the next run starts over from the beginning. That's safe (bans are replaced by `id`), just slower. To keep the cursor through cache clears, store it in a database table instead.

## 6. Run it every five minutes

Laravel 11, in `routes/console.php`:

```php
use Illuminate\Support\Facades\Schedule;

Schedule::command('sentinel:sync')->everyFiveMinutes()->withoutOverlapping();
```

Laravel 10, in `app/Console/Kernel.php`:

```php
protected function schedule(Schedule $schedule): void
{
    $schedule->command('sentinel:sync')->everyFiveMinutes()->withoutOverlapping();
}
```

Both need Laravel's scheduler in the server's crontab:

```bash
* * * * * cd /var/www/your-panel && php artisan schedule:run >> /dev/null 2>&1
```

## 7. Check players when they join

Check the local table first: it's instant and costs no API calls. Put this wherever your panel learns a player connected (a game-server webhook, a log watcher, a queue job):

```php
use App\Models\SentinelBan;

$bans = SentinelBan::active()->forArmaUid($armaUid)->get();

if ($bans->isEmpty()) {
    return; // nothing on the network
}

if (SentinelBan::active()->verified()->forArmaUid($armaUid)->exists()) {
    // Verified network ban: kick, block, or alert staff - your community's rule
} else {
    // Active but unverified: flag the player for staff to look at
}
```

For identifiers you don't sync, such as a Discord ID from your whitelist form, ask the API directly:

```php
$result = app(\App\Services\SentinelShare::class)->lookup(['discord_id' => $discordId]);

if ($result['found']) {
    $active = collect($result['bans'])->where('status', 'active');
    // $result['url'] links staff to the player's SentinelShare profile
}
```

## 8. Show it on a player page

A Blade partial for your player view:

```blade
@php($networkBans = \App\Models\SentinelBan::forArmaUid($player->arma_uid)->orderByDesc('filed_at')->get())

@if ($networkBans->isNotEmpty())
  <div class="alert alert-warning">
    <strong>SentinelShare:</strong>
    {{ $networkBans->where('status', 'active')->count() }} active network ban(s)
    <ul>
      @foreach ($networkBans as $ban)
        <li>
          {{ ucfirst(strtolower(str_replace('_', ' ', $ban->type))) }},
          {{ $ban->filed_by_community }}, {{ $ban->filed_at?->format('d M Y') }},
          {{ $ban->status }}, {{ strtolower(str_replace('_', ' ', $ban->verification_state)) }}
          <a href="{{ $ban->record['url'] }}" target="_blank" rel="noopener">profile</a>
        </li>
      @endforeach
    </ul>
  </div>
@endif
```

## Plain PHP (no framework)

A lookup with cURL:

```php
<?php

function sentinel_lookup(array $identifiers): array
{
    $url = 'https://sentinelshare.com/api/v1/integration/lookup?' . http_build_query(array_filter($identifiers));
    $ch = curl_init($url);
    curl_setopt_array($ch, [
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_TIMEOUT => 10,
        CURLOPT_HTTPHEADER => [
            'Authorization: Bearer ' . getenv('SENTINEL_API_KEY'),
            'Accept: application/json',
        ],
    ]);
    $body = curl_exec($ch);
    $status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
    curl_close($ch);

    if ($status !== 200) {
        throw new RuntimeException("SentinelShare lookup failed: HTTP {$status}");
    }

    return json_decode($body, true);
}

$result = sentinel_lookup(['arma_uid' => 'f91f26e6-aea4-4822-b26b-c9135f3c3b66']);
echo $result['found'] ? count($result['bans']) . " bans\n" : "clean\n";
```
