DatastoreHasPrimaryKey Interface

The DatastoreHasPrimaryKey interface extends Datastore to add primary key-based operations. It provides the find() method for fast single-record lookups by ID—one of the most common operations in data access.

This interface assumes your storage uses a single integer primary key (typically named id). If your model uses compound keys or non-integer identifiers, this interface may not apply.

Interface Definition

interface DatastoreHasPrimaryKey extends Datastore
{
    /**
     * Finds a single record by its primary key.
     *
     * @param int $id The primary key value
     * @return Model The matching model
     * @throws RecordNotFoundException if no record exists with the given ID
     */
    public function find(int $id): Model;
}

Method

find(int $id): Model

Retrieves a single model by its primary key value.

Parameters:

Returns:

Throws:

When to use:

Example: basic lookup

try {
    $post = $postDatastore->find(42);
    echo $post->title;
} catch (RecordNotFoundException $e) {
    echo "Post not found";
}

Example: loading related entity

$post = $postDatastore->find(123);

// Load the author using the foreign key
$author = $authorDatastore->find($post->authorId);

echo "Post '{$post->title}' by {$author->name}";

Why This Interface Exists

Primary key lookups are:

By separating find() into its own interface, PHPNomad allows datastores to opt in or out based on their storage model. For example:

Usage Patterns

Service Layer Integration

Services typically depend on DatastoreHasPrimaryKey when they need ID-based lookups:

final class PublishPostService
{
    public function __construct(
        private PostDatastore $posts // Assumes DatastoreHasPrimaryKey
    ) {}

    public function publish(int $postId): void
    {
        $post = $this->posts->find($postId);

        // Business logic: create new model with updated date
        $publishedPost = new Post(
            id: $post->id,
            title: $post->title,
            content: $post->content,
            authorId: $post->authorId,
            publishedDate: new DateTime() // Set publish date
        );

        $this->posts->save($publishedPost);
    }
}

REST Controller Example

REST endpoints often map directly to find():

final class GetPostController implements Controller
{
    public function __construct(
        private PostDatastore $posts,
        private Response $response
    ) {}

    public function getEndpoint(): string
    {
        return '/posts/{id}';
    }

    public function getMethod(): string
    {
        return Method::Get;
    }

    public function getResponse(Request $request): Response
    {
        $id = (int) $request->getParam('id');

        try {
            $post = $this->posts->find($id);
            
            return $this->response
                ->setStatus(200)
                ->setJson(['post' => $post]);
        } catch (RecordNotFoundException $e) {
            return $this->response
                ->setStatus(404)
                ->setJson(['error' => 'Post not found']);
        }
    }
}

Error Handling

Always handle RecordNotFoundException when calling find():

// ✅ GOOD: explicit error handling
try {
    $post = $postDatastore->find($id);
    // ... use post
} catch (RecordNotFoundException $e) {
    // Handle gracefully
}

// ❌ BAD: unhandled exception crashes the application
$post = $postDatastore->find($id); // May throw!

Relationship to Other Interfaces

vs. get()

Both find() and get() can fetch records, but they serve different purposes:

Method Returns When Not Found Use Case
find($id) Single model Throws exception Known ID, expect one result
get(['id' => $id]) Iterable (0 or 1 item) Empty iterable Query by criteria, may return none

Example comparison:

// Using find() - throws if not found
try {
    $post = $datastore->find(42);
} catch (RecordNotFoundException $e) {
    // Handle not found
}

// Using get() - returns empty if not found
$posts = $datastore->get(['id' => 42]);
if (empty($posts)) {
    // Handle not found
}
$post = $posts[0] ?? null;

Use find() when you expect the record to exist. Use get() when existence is uncertain.

Combining with Other Extensions

Most datastores implement multiple interfaces:

interface PostDatastore extends 
    Datastore,
    DatastoreHasPrimaryKey,
    DatastoreHasWhere,
    DatastoreHasCounts
{
    // get(), save(), delete() from Datastore
    // find() from DatastoreHasPrimaryKey
    // where() from DatastoreHasWhere
    // count() from DatastoreHasCounts
}

This gives consumers a full set of operations.

Implementation with Decorator Traits

If your Core implementation just delegates to a handler, use WithDatastorePrimaryKeyDecorator:

final class PostDatastoreImpl implements PostDatastore
{
    use WithDatastorePrimaryKeyDecorator;

    public function __construct(
        private DatastoreHandlerHasPrimaryKey $handler
    ) {}
}

The trait provides find(), get(), save(), and delete() automatically.

Implementation Notes

When implementing this interface:

When NOT to Implement This Interface

Skip DatastoreHasPrimaryKey if:

What's Next