> ## Documentation Index
> Fetch the complete documentation index at: https://docs.fetchski-attract.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Key in the database

> Keep the site's API key and signing secret in your database, encrypted, and replace them without touching .env.

By default the SDK reads the site's API key from `.env`. You can keep it in your app's database instead,
encrypted, and replace it from the terminal, with no redeploy and no `.env` edit. That suits an app
where the key is rotated now and then, or where the person who holds the key isn't the person who
deploys the app.

> [card] Encrypted at rest
>
> The key and its signing secret are encrypted with your app's own `APP_KEY`, and never appear in
> `toArray()`, JSON or a dump.

> [card] Rotated in seconds
>
> Swap in a new key from the CMS and the old one is gone from your app.

> [card] Same calls
>
> `Attract::blogPosts()` and every other call work as before, signed as the key asks.

## What the package gives you

You write no table, model or lookup code. The package ships all of it:

> [card] The migration
>
> Creates the `attract_api_keys` table, one row per site. It's published into your app's
> `database/migrations/` like any package migration.

> [card] The model
>
> `Fetchski\AttractCmsLaravel\Models\AttractApiKey`, inside the package. It encrypts the key and
> secret, hides them from `toArray()` and JSON, and turns its row into the site the SDK calls.

> [card] The lookup
>
> One line in `.env` switches the SDK from `config/fetchski-attract.php` to the table.

## Before you start

**1. The package is installed**

From your own site, with any of its API keys: see [Installation](https://docs.fetchski-attract.com/laravel/installation.md).

**2. Your app has a database connection**

Any database Laravel supports: the one your app already uses. The key is encrypted with your app's
existing `APP_KEY`, which nothing here changes.

**3. You have an API key from the site**

In the site's CMS, **API Keys → New API Key** shows three things, once. Keep the page open until
you've stored them:

* the **API key**, `fsk_acme_…`
* its **signing secret**, `fss_…` (none for a token-only key)
* its **signing mode**: the key's **Request signing** setting, on its **Security** tab

## Set it up

> [tab] New install
>
> Run:
>
> ```bash Terminal theme={"dark"}
> php artisan attract:install --database
> ```
>
> In order, it:
>
> 1. publishes `config/fetchski-attract.php` and the migration,
>    `database/migrations/<date>_create_attract_api_keys_table.php`;
> 2. writes `FETCHSKI_ATTRACT_SITES_FROM=database` to `.env` (and an empty one to `.env.example`). It
>    never writes the key to either;
> 3. asks **Run the migrations now?** before it runs your app's migrations;
> 4. asks for the key ([the prompts](#the-prompts)), stores it, and checks it on the site.
>
> Answer **no** at step 3 and it stops there, so you can review the migration first. When you're
> ready, run your migrations:
>
> ```bash Terminal theme={"dark"}
> php artisan migrate
> ```
>
> Then store the key:
>
> ```bash Terminal theme={"dark"}
> php artisan attract:store-key
> ```
>
> > [!TIP]
> >
> > In a deploy script, skip the migration question with `--force`:
> >
> > ```bash Terminal theme={"dark"}
> > php artisan attract:install --database --force
> > ```

> [tab] Moving from .env
>
> Already installed with the key in `.env`? Move it across:
>
> **1. Publish the migration**
>
> ```bash Terminal theme={"dark"}
> php artisan vendor:publish --tag=attract-migrations
> ```
>
> **2. Run it**
>
> ```bash Terminal theme={"dark"}
> php artisan migrate
> ```
>
> **3. Switch the SDK to the table**
>
> Add this line to `.env`:
>
> ```ini .env theme={"dark"}
> FETCHSKI_ATTRACT_SITES_FROM=database
> ```
>
> If your app caches its config, cache it again so the new line is read:
>
> ```bash Terminal theme={"dark"}
> php artisan config:cache
> ```
>
> **4. Store the key**
>
> ```bash Terminal theme={"dark"}
> php artisan attract:store-key
> ```
>
> **5. Take the key out of .env**
>
> Delete these four lines from `.env` (and from your host's environment settings, if you set
> them there):
>
> ```ini .env theme={"dark"}
> FETCHSKI_ATTRACT_SITE=acme
> FETCHSKI_ATTRACT_URL=https://acme.example
> FETCHSKI_ATTRACT_API_KEY=fsk_acme_…
> FETCHSKI_ATTRACT_SIGNING_SECRET=fss_…
> ```
>
> Remove all four together: while `FETCHSKI_ATTRACT_SITE` is set, the app still checks that
> site's `.env` entry when it boots.

### The prompts

Storing the key asks for what the CMS showed when the key was made:

| Prompt | Your answer |
| - | - |
| **The site's URL** | The site's public address, e.g. `https://acme.example` |
| **The API key** | `fsk_acme_…` (hidden as you type) |
| **Its signing secret** | `fss_…` (hidden). Leave it empty only for a token-only key |
| **Its signing mode** | Pick the one that matches the key's **Request signing** setting: **Signed writes** for *Token + signature on writes*, **Every request signed** for *Token + signature on every request*, **Token only** for *Token only* |

You don't type the site's slug: the SDK reads it from the key (the `acme` in `fsk_acme_…`). Once the
row is stored, the SDK calls the site with it and reports three things: the site is reachable, the key
is accepted, and your server's clock is close enough to the site's to sign requests.

```text Terminal theme={"dark"}
 INFO  Stored the API key for acme (https://acme.example), encrypted.
```

> [!NOTE]
>
> The table is checked before you're asked for anything. With no table yet, you're told to run the
> install above, or your migrations when the migration is already published. With the table there but
> `FETCHSKI_ATTRACT_SITES_FROM` not set to `database`, you're told to set it.

## Call the site

Nothing changes in your code. With one row in the table, that row is the default site:

```php app/Actions/LatestPosts.php theme={"dark"}
<?php

namespace App\Actions;

use Fetchski\AttractCmsLaravel\Facades\Attract;
use Illuminate\Support\Collection;

class LatestPosts
{
    public function __invoke(): Collection
    {
        return Attract::blogPosts()->listBlogs()->results;
    }
}
```

## Rotate the key

When you have a new key for the site from its CMS, swap it in:

```bash Terminal theme={"dark"}
php artisan attract:rotate-key
```

It keeps the site's row and URL, asks for the new key, its signing secret and its signing mode (the
current one is offered first), and refuses a key that belongs to another site. The old key and secret
are gone from the table the moment the new ones are stored, then the new key is checked on the site.
With more than one site in the table, name the one to rotate:

```bash Terminal theme={"dark"}
php artisan attract:rotate-key acme
```

The old key keeps working on the site until you switch it off in its CMS.

Long-running processes keep the client they already made. If your app runs queue workers, restart
them so they pick up the new key:

```bash Terminal theme={"dark"}
php artisan queue:restart
```

If it runs on Octane, reload it:

```bash Terminal theme={"dark"}
php artisan octane:reload
```

### In a script or a deploy

Give every value as an option and nothing is asked:

```bash Terminal theme={"dark"}
php artisan attract:rotate-key acme \
  --key=fsk_acme_… \
  --secret=fss_… \
  --signing=writes
```

| Option | Value |
| - | - |
| `--key` | The new API key, `fsk_…` |
| `--secret` | Its signing secret, `fss_…` (leave it out for a token-only key) |
| `--signing` | `writes`, `all` or `none` (default: the current one) |
| `--no-check` | Skip the call to the site at the end |

Every command and option is on [Artisan commands](https://docs.fetchski-attract.com/laravel/artisan-commands.md).

> [!WARNING]
>
> Options are saved in your shell history and can show up in a process list. Read them from your secret
> store in a script, and use the prompts when you type it yourself.

## Forget the key

To take the key out of your app, delete its row:

```bash Terminal theme={"dark"}
php artisan attract:forget-key
```

You're asked **Delete the API key for acme from the attract\_api\_keys table?** first. If the table holds
keys for more than one site, name the site:

```bash Terminal theme={"dark"}
php artisan attract:forget-key acme
```

> [!WARNING]
>
> Deleting the row doesn't switch the key off: it keeps working on the site until you revoke it there too,
> in the CMS under **API Keys**.

## The table and its model

The migration creates this table:

| Column | Holds |
| - | - |
| `id` | The row's id |
| `slug` | The site's slug, the `acme` in `fsk_acme_…` (unique) |
| `url` | The site's public address |
| `token` | The API key, `fsk_…`, **encrypted** |
| `signing_secret` | The signing secret, `fss_…`, **encrypted**; empty for a token-only key |
| `signing` | The signing mode: `none`, `writes` or `all` |
| `created_at`, `updated_at` | Timestamps |

`Fetchski\AttractCmsLaravel\Models\AttractApiKey` is an ordinary Eloquent model over it, so you can query
it like any other, for an admin page that shows which site the app is connected to, say:

```php theme={"dark"}
use Fetchski\AttractCmsLaravel\Models\AttractApiKey;

$key = AttractApiKey::first();

$key->slug;       // 'acme'
$key->url;        // 'https://acme.example'
$key->updated_at; // when the key was last stored
```

`$key->token` and `$key->signing_secret` read back decrypted, so never print or log them. `toArray()`
and `toJson()` leave them out.

### Add your own columns

Extend the model, add the columns in a migration of your own, and point the config at your class.

**1. Make the migration**

```bash Terminal theme={"dark"}
php artisan make:migration add_notes_to_attract_api_keys_table
```

```php database/migrations/xxxx_xx_xx_xxxxxx_add_notes_to_attract_api_keys_table.php theme={"dark"}
<?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::table('attract_api_keys', function (Blueprint $table) {
            $table->string('notes')->nullable();
        });
    }

    public function down(): void
    {
        Schema::table('attract_api_keys', function (Blueprint $table) {
            $table->dropColumn('notes');
        });
    }
};
```

**2. Run it**

```bash Terminal theme={"dark"}
php artisan migrate
```

**3. Extend the model**

Keep the parent's fillable columns and add yours:

```php app/Models/AttractApiKey.php theme={"dark"}
<?php

namespace App\Models;

use Fetchski\AttractCmsLaravel\Models\AttractApiKey as BaseAttractApiKey;

class AttractApiKey extends BaseAttractApiKey
{
    protected $fillable = ['slug', 'url', 'token', 'signing_secret', 'signing', 'notes'];
}
```

**4. Point the config at it**

In `config/fetchski-attract.php` (published by the install):

```php config/fetchski-attract.php theme={"dark"}
'model' => App\Models\AttractApiKey::class,
```

The SDK now uses your model, for storing and forgetting keys too. A class that doesn't extend the
package's model is refused, with a message saying so.

## Test with it

`Attract::fake()` needs no table, so tests that fake the site stay as they are
([Testing](https://docs.fetchski-attract.com/laravel/testing.md)). To test code that reads the stored key, store one in the test database:

```php tests/Feature/AttractKeyTest.php theme={"dark"}
<?php

namespace Tests\Feature;

use Fetchski\AttractCmsLaravel\Models\AttractApiKey;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Tests\TestCase;

class AttractKeyTest extends TestCase
{
    use RefreshDatabase;

    public function test_the_key_is_stored_encrypted(): void
    {
        $key = AttractApiKey::create([
            'slug' => 'acme',
            'url' => 'https://acme.example',
            'token' => 'fsk_acme_0a1b2c3d_not-a-real-secret',
            'signing_secret' => 'fss_not-a-real-signing-secret',
            'signing' => 'writes',
        ]);

        $this->assertSame('acme', $key->toSite()->slug);
        $this->assertStringNotContainsString('not-a-real', (string) DB::table('attract_api_keys')->value('signing_secret'));
    }
}
```

> [!WARNING]
>
> The stored key can only be read with the `APP_KEY` it was encrypted with. If your app's `APP_KEY` is
> ever rotated, Laravel reads the stored key with the old one as long as it's listed in
> `APP_PREVIOUS_KEYS`. To move the key under the new `APP_KEY` for good, make a new API key in the CMS
> and swap it in:
>
> ```bash Terminal theme={"dark"}
> php artisan attract:rotate-key
> ```


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.