A lightweight CRUD, based on the Table Data Gateway pattern
In list(), because $page is a single-use variable, I don't know that it is worth decrementing.  Perhaps write just one line as:
$offset = ($page - 1) * $limit;

but then again $offset is a single-use variable.  If you prefer the declarative coding style, leave it as is.  Alternatively, you could just write the arithmetic directly in the execute() call.  (either way, don't forget to add the space between offset and limit arguments)
$stmt-&gt;execute([($page - 1) * $limit, $limit]);

or because of precedence, you can pre-decrement to avoid parentheses:
$stmt-&gt;execute([--$page * $limit, $limit]);

or as stated in comments, &quot;list can internally call listBySql&quot; from @hjpotter92:
public function list(int $page = 1, int $limit = 50): array
{
    return listBySQL(&quot;SELECT * FROM `$this-&gt;table` LIMIT ?,?&quot;, [--$page * $limit, $limit]);
}


Please add the int type declaration to the $id argument in read().  I think that read() can be reduced to this:
return reset($this-&gt;listBySQL(&quot;SELECT * FROM `$this-&gt;table` WHERE `$this-&gt;primary`=?&quot;, [$id])) ?: null;

because if fetchAll() delivers an empty array, then reset() will return false in which case the Elvis operator can return the desired null value.  This is more of a style preference alternative rather than a functional gain -- the only real benefit is removing the $ret variable.

I would prefer that update() return int instead of void (even if your demo doesn't seek this information).  In my projects, I always return the number of affected rows.  This is integral in knowing if the query was actually successful in making changes or if there were no changes from the valid query.  I find this to be as important as insert() returning the new id.

Please add the array type declaration on $data in insert() and remove the blank line after returning the new id in insert().

Please add the int type declaration on $id in delete().

There is some inconsistency regarding when you declare a $sql variable and when you write the sql string directly into the prepare() call.  I think you should pick one style or the other.  My leanings, of course, bias toward removing single-use variables.

validate()'s incoming argument should have an array type declaration.  The implode() call in the thrown exception could use some glue -- a comma at minimum.