All news

Persist a reading list with PHPAML Data

Build a complete SQLite-backed reading list, from typed entities and migrations to CRUD, transactions, validation, AML View integration, loading states and reliable tests.

A useful data layer is not merely a collection of methods named add, update and delete. It must give the application a clear model, protect its invariants, explain failures and remain predictable when several changes must succeed together. In this guide, we will build a small reading-list application around those principles. A reader can add a book, choose its reading status, record progress, edit it and remove it. The interface will be written with AML View, while PHPAML Data will own persistence.

The project is intentionally small enough to understand in one sitting, but complete enough to expose the decisions that matter in a real application. We will begin with SQLite because it requires no database server. The same entities and query concepts can later target MySQL, MariaDB or PostgreSQL. PHPAML Data is currently alpha software, so pin its version and read its changelog before upgrading.

1. Understand the boundary before writing code

AML View and PHPAML Data solve different problems. AML View describes what the user sees and how the browser reacts. PHPAML Data describes how PHP entities are stored, queried and changed. A controller or an application service sits between them. It receives an intention such as ‘add this book’, validates the request, asks the data context to perform the operation and returns a safe result to the interface.

This separation is practical rather than ceremonial. A page should not construct SQL, and an entity should not know about buttons or HTTP responses. Because PHPAML Data is independent from the presentation layer, the same Book entity can be used by an AML View application, a classic PHPAML application, an API or a standalone PHP script.

The final structure will look like this:

phpaml — zsh
src/
  controllers/
    ReadingListController.php
  models/
    Book.php
  Data/
    AppDbContext.php
  views/
    pages/
      reading-list/
        page.php
    components/
      BookCard.php
runtime/
  storage/
    app.sqlite
  database/
    migrations/
      202609020001_create_books_table.php
public/
  index.php
phpaml.json

The SQLite file belongs under runtime because it is generated application state, not source code. It must not be committed. Migrations, on the other hand, describe the history of the schema and should be versioned.

2. Install PHPAML Data and verify the environment

From the project root, install the data package with the SQLite driver. The installer prepares the folders, adds the package and writes the normalized data configuration to phpaml.json.

phpaml — zsh
aml install data --driver sqlite
aml data:doctor

The doctor command should confirm the PDO driver, the resolved database connection and capabilities such as transactions and foreign keys. It deliberately masks secrets in its output. Run it before debugging application code: an unavailable PDO extension or an invalid path cannot be fixed in a controller.

A modern phpaml.json contains a data section similar to the following. Prefer project-relative paths so that development, CI and deployment resolve the same location.

phpaml — zsh
{
  "data": {
    "default": "main",
    "connections": {
      "main": {
        "driver": "sqlite",
        "database": "runtime/storage/app.sqlite"
      }
    }
  }
}

Do not place credentials in phpaml.json for server databases. Put secrets in .env and let the configuration reference the environment. Keep .env out of Git and provide a documented .env.example containing names but no real credentials.

3. Model a book as a typed entity

Generate a model, then replace the generated example with a focused entity. Public typed properties make the stored shape visible to the reader and to static-analysis tools. Attributes describe persistence and validation without forcing the model to depend on a page.

phpaml — zsh
aml make:model Book
phpaml — zsh
<?php

namespace App\Models;

use AML\Data\Entity;
use AML\Data\Metadata\{Column, Key, Table};
use AML\Data\Validation\{Length, Required};

#[Table('books')]
final class Book extends Entity
{
    #[Key]
    public int $id;

    #[Required]
    #[Length(min: 1, max: 180)]
    public string $title;

    #[Required]
    #[Length(min: 1, max: 120)]
    public string $author;

    #[Column('reading_status')]
    public string $status = 'to_read';

    #[Column('current_page')]
    public int $currentPage = 0;

    #[Column('total_pages')]
    public int $totalPages = 1;

    #[Column('created_at')]
    public string $createdAt;

    #[Column('updated_at')]
    public string $updatedAt;
}

The identifier is left uninitialized for a new entity. SQLite generates it and PHPAML Data writes the resulting identity back to the same PHP object. That identity agreement matters: later find, update and remove operations must address the exact stored row. If your domain uses manually assigned identifiers, define that contract deliberately instead of mixing generated and manual keys.

The default status is a domain decision. Keep allowed values in one place—for example to_read, reading and finished—and reject anything else in the controller or a dedicated service. The database column names remain conventional snake_case while PHP properties remain idiomatic camelCase.

4. Create the schema with a reversible migration

Generate a migration and describe both directions. The up method creates the schema; down reverses it. A migration must be deterministic: avoid reading request data or calling external APIs from it.

phpaml — zsh
aml make:migration create_books_table
phpaml — zsh
<?php

use AML\Data\Connection;
use AML\Data\Migrations\Migration;
use AML\Data\Schema\{Schema, Table};

return new class extends Migration {
    public function up(Connection $connection): void
    {
        (new Schema($connection))->create('books', function (Table $table): void {
            $table->id();
            $table->string('title', 180);
            $table->string('author', 120);
            $table->string('reading_status', 24)->default('to_read');
            $table->integer('current_page')->default(0);
            $table->integer('total_pages')->default(1);
            $table->timestamps();
            $table->index(['reading_status', 'updated_at']);
        });
    }

    public function down(Connection $connection): void
    {
        (new Schema($connection))->dropIfExists('books');
    }
};

Apply the migration, inspect its status and practise one rollback locally before the application contains important data.

phpaml — zsh
aml data:migrate
aml data:status
aml data:rollback --steps 1
aml data:migrate

The composite index supports the future query ‘show books with this status, most recently changed first’. Do not add indexes blindly: each index costs space and makes writes slightly more expensive. Add them for real query patterns.

5. Give the application one data context

A DbContext represents a coherent data session. It exposes named sets so the rest of the application does not repeatedly pass entity class names.

phpaml — zsh
<?php

namespace App\Data;

use AML\Data\DbContext;
use AML\Data\DbSet;
use App\Models\Book;

final class AppDbContext extends DbContext
{
    /** @return DbSet<Book> */
    public function books(): DbSet
    {
        return $this->set(Book::class);
    }
}

Create the context through PHPAML’s configured connection manager in the application bootstrap or dependency container, then inject it where needed. Avoid opening a fresh connection inside every controller method. The application owns the context lifetime; the entity remains a plain domain object.

6. Insert the first book safely

A controller should translate untrusted input into a valid entity. Trimming, numeric conversion and domain checks happen before persistence. The data package attributes provide a second validation layer; they do not remove the need for an understandable HTTP error response.

phpaml — zsh
public function store(Request $request): Response
{
    $title = trim((string) $request->input('title', ''));
    $author = trim((string) $request->input('author', ''));
    $totalPages = filter_var(
        $request->input('total_pages'),
        FILTER_VALIDATE_INT,
        ['options' => ['min_range' => 1]],
    );

    if ($title === '' || $author === '' || $totalPages === false) {
        return Response::json([
            'error' => 'Title, author and a positive page count are required.',
        ], 422);
    }

    $book = new Book();
    $book->title = $title;
    $book->author = $author;
    $book->totalPages = $totalPages;
    $book->createdAt = gmdate('Y-m-d H:i:s');
    $book->updatedAt = $book->createdAt;

    $this->db->books()->add($book);

    return Response::json(['book' => $book], 201);
}

DbSet CRUD is immediate, so add inserts the row at that point. After success, book.id contains the generated database identity. Return 201 for creation and 422 for a syntactically valid request whose fields violate the application contract. Never trust the browser’s required attribute as the only validation; clients can call the endpoint directly.

7. Query without leaking SQL into the page

DbSet query methods return clones. This means a reusable set does not keep an old filter by accident. Values are bound as prepared-statement parameters, and field names are checked against entity metadata.

phpaml — zsh
$status = $request->query('status', 'all');
$page = max(1, (int) $request->query('page', 1));

$query = $this->db->books()->orderBy('updated_at', 'desc');

if (in_array($status, ['to_read', 'reading', 'finished'], true)) {
    $query = $query->where('reading_status', '=', $status);
}

$result = $query->paginate(page: $page, perPage: 12);
return Response::json($result);

Pagination is part of the data boundary, not a visual afterthought. Loading ten thousand rows and slicing them in PHP wastes memory and makes response time grow with the database. Keep filters explicit, whitelist sort fields supplied by users and choose a stable secondary order when equal values are possible.

To fetch one book, use find with the primary key and distinguish ‘not found’ from a server failure.

phpaml — zsh
$book = $this->db->books()->find($id);
if ($book === null) {
    return Response::json(['error' => 'Book not found.'], 404);
}

8. Update progress while protecting invariants

Reading progress has rules: the current page cannot be negative or exceed the total, and reaching the final page can mark the book as finished. Load the tracked entity, modify its public properties, then call saveChanges. PHPAML Data detects changes made to loaded entities.

phpaml — zsh
public function progress(int $id, Request $request): Response
{
    $book = $this->db->books()->find($id);
    if ($book === null) {
        return Response::json(['error' => 'Book not found.'], 404);
    }

    $page = filter_var($request->input('page'), FILTER_VALIDATE_INT);
    if ($page === false || $page < 0 || $page > $book->totalPages) {
        return Response::json(['error' => 'Invalid reading progress.'], 422);
    }

    $book->currentPage = $page;
    $book->status = $page === $book->totalPages ? 'finished' : 'reading';
    $book->updatedAt = gmdate('Y-m-d H:i:s');
    $this->db->saveChanges();

    return Response::json(['book' => $book]);
}

An update with no changed fields should be treated as a harmless no-op by the application rather than forcing meaningless SQL. If concurrent edits matter, add an explicit version or updated-at precondition and return 409 when the client updates stale data.

9. Delete deliberately

Deletion is easy to code and difficult to undo. Fetch the entity, authorize the operation, then remove it. Return 204 only when no body follows.

phpaml — zsh
public function destroy(int $id): Response
{
    $book = $this->db->books()->find($id);
    if ($book === null) {
        return Response::json(['error' => 'Book not found.'], 404);
    }

    $this->db->books()->remove($book);
    return new Response('', 204);
}

For user-generated content, consider soft deletion or an undo period. Whatever policy you choose, enforce ownership on the server. Hiding a delete button is not authorization.

10. Use a transaction for one business operation

A transaction is useful when partial success would be incorrect. Imagine finishing one book and creating a journal entry. Both changes should be committed, or neither should be visible. PHPAML Data rolls a failed transaction back automatically. Nested SQL transactions use savepoints when the driver supports them.

phpaml — zsh
$this->db->transaction(function (AppDbContext $db) use ($book, $journal): void {
    $book->status = 'finished';
    $book->currentPage = $book->totalPages;
    $book->updatedAt = gmdate('Y-m-d H:i:s');

    $db->add($journal);
    $db->saveChanges();
});

Keep transactions short. Do not call an AI service, send email or wait for a file upload while holding a database transaction open. External effects cannot be rolled back with SQL; use an outbox or a queued job when they must follow a committed change.

11. Connect the API to AML View

The browser should consume a narrow API rather than know about SQLite. The page owns visual and reactive state: books, filters, pending actions and user-facing errors. The server remains the source of truth.

phpaml — zsh
<?php

namespace App\Views\Pages\ReadingList;

use AML\Engine\{Api, StateRef};
use AML\View\{CollectionItem, Page, State, View};
use function AML\View\{Button, Each, Form, Heading, Input, Text, VStack};

final class ReadingListPage extends Page
{
    #[State] public array $books = [];
    #[State] public string $title = '';
    #[State] public string $author = '';
    #[State] public bool $loading = false;
    #[State] public string $error = '';

    public function body(): View
    {
        return VStack(
            Heading('My reading list'),
            Form(
                Input('title')->bindClient('title')->required('A title is required.'),
                Input('author')->bindClient('author')->required('An author is required.'),
                Button('Add book')->onClick(
                    Api::post('/api/books', [
                        'title' => StateRef::to('title'),
                        'author' => StateRef::to('author'),
                    ])
                        ->storeIn('books', 'books')
                        ->loadingIn('loading')
                        ->errorIn('error')
                ),
            ),
            Text(StateRef::to('error', $this->error))->class('form-error'),
            Each(
                StateRef::to('books', $this->books),
                key: 'id',
                render: static fn (CollectionItem $book): View =>
                    Element('article', $book->text('title'))->class('book-card'),
            ),
        )->class('reading-list');
    }
}

Names may differ slightly according to the AML View component helpers used by your project, but the architectural rule stays constant: declarative elements describe the UI, client actions call the API, and PHPAML Data never runs in the browser. Use stable entity identifiers as collection keys so the engine can update the correct card without rebuilding the entire list.

12. Design loading, empty, error and optimistic states

A production interface has more than ‘data’ and ‘no data’. On the first request, show a loading skeleton. When the request succeeds with zero books, show an empty-state explanation and a clear creation action. When it fails, keep the page usable, display a retry button and preserve the form where appropriate.

For creation, disable the submit button while the request is pending and reject an empty title on both client and server. For progress updates, an optimistic UI can move the progress bar immediately, but it must restore the previous value if the server rejects the change. For destructive actions, wait for confirmation and remove the card only after success, or provide a reliable rollback path.

A useful state model is:

phpaml — zsh
idle -> loading -> ready
               -> empty
               -> error -> retry -> loading
ready -> saving -> ready
                -> error with previous data preserved

Do not replace an existing list with a blank screen during a background refresh. Preserve useful content and indicate that synchronization is in progress. Error messages should tell the reader what can be done next, while detailed exceptions stay in server logs.

13. Test the behavior, not only the happy path

Use a temporary SQLite database for automated tests. Apply the real migration, create a new context for each test and delete the temporary file afterward. The test should prove identity synchronization, querying, tracked updates, deletion and rollback.

phpaml — zsh
$path = sys_get_temp_dir() . '/phpaml-reading-' . bin2hex(random_bytes(6)) . '.sqlite';
$connection = Connection::sqlite($path);
$db = new AppDbContext($connection);

$book = new Book();
$book->title = 'The Left Hand of Darkness';
$book->author = 'Ursula K. Le Guin';
$book->totalPages = 304;
$book->createdAt = $book->updatedAt = gmdate('Y-m-d H:i:s');
$db->books()->add($book);

assert(isset($book->id));
assert($db->books()->find($book->id)?->title === $book->title);

$loaded = $db->books()->find($book->id);
$loaded->currentPage = 42;
$loaded->status = 'reading';
$db->saveChanges();
assert($db->books()->find($book->id)?->currentPage === 42);

Add a rollback test by inserting inside a transaction and throwing an exception. After catching it, the row count must be unchanged. Also test empty strings, an invalid status, a page greater than totalPages, an unknown identifier and a repeated request. Browser tests should confirm that empty submissions are blocked, loading and error states are visible, navigation does not cause a full reload and the updated progress survives a refresh.

14. Prepare the same design for another database

SQLite is an excellent default for learning, prototypes and many single-node applications. Moving to MySQL, MariaDB or PostgreSQL changes the connection, not the purpose of the entity or controller. Run the real integration tests for the chosen server and review differences in generated identifiers, date handling, indexes and locking. Use aml data:doctor after every environment change.

MongoDB is available through the separate phpaml/data-mongodb adapter. It follows document semantics and ObjectId rules; do not pretend that every SQL relation or migration concept is portable. Choose a store for the application’s access patterns rather than because changing one configuration line looks attractive.

Summary

You now have the complete path from a typed Book entity to a reactive reading-list interface. The model names the data, the migration versions its schema, AppDbContext creates a clear persistence boundary, the controller protects input and business rules, and AML View presents the result without knowing how it is stored. Transactions protect multi-step operations, while loading, empty and error states make the interface honest.

Before deploying, run migrations on a backup-aware workflow, keep the SQLite file outside public, verify filesystem permissions, execute data:doctor and run both persistence and browser tests. PHPAML Data remains alpha software: pin 0.2.0-alpha.1, review its changelog and test upgrades on staging. A small application becomes trustworthy not because it has many layers, but because every boundary has one clear responsibility and every failure has been considered.