A lightweight CRUD, based on the Table Data Gateway pattern In order to reduce the boilerplate code, I decided to write a lightweight database helper, based on the  Table Data Gateway pattern (or at least the way I understand it) with the aim of using it for a simple CRUD application.
Writing INSERT and UPDATE queries is always a nuisance, repeating all those ranks of column names, parameter names, binding parameters and such. So I decided to write a simple class that does all the job under the hood, and accepts/returns simple arrays, without going down the rabbit hole of creating a full featured ORM.
My aim was also to make this class as secure as possible, hence the table and field names are always hardcoded.
It consists of one prototype class,
abstract class BasicTableGateway
{
    protected $db;
    protected $table;
    protected $fields;
    protected $primary = 'id';

    public function __construct(\PDO $db)
    {
        $this-&gt;db = $db;
    }
    public function listBySQL(string $sql, array $params): array
    {
        $stmt = $this-&gt;db-&gt;prepare($sql);
        $stmt-&gt;execute($params);
        return $stmt-&gt;fetchAll();
    }
    public function list(int $page = 1, int $limit = 50): array
    {
        $page--;
        $offset = $page * $limit;
        $stmt = $this-&gt;db-&gt;prepare(&quot;SELECT * FROM `$this-&gt;table` LIMIT ?,?&quot;);
        $stmt-&gt;execute([$offset,$limit]);
        return $stmt-&gt;fetchAll();
    }
    public function read($id): ?array
    {
        $ret = $this-&gt;listBySQL(&quot;SELECT * FROM `$this-&gt;table` WHERE `$this-&gt;primary`=?&quot;, [$id]);
        return $ret ? reset($ret) : null;
    }
    public function update(array $data, int $id): void
    {
        $this-&gt;validate($data);

        $params = [];
        $set = &quot;&quot;;
        foreach($data as $key =&gt; $value)
        {
            $set .= &quot;`$key` = ?,&quot;;
            $params[] = $value;
        }
        $set = rtrim($set, &quot;,&quot;);
        $params[] = $id;
        $sql = &quot;UPDATE `$this-&gt;table` SET $set WHERE `$this-&gt;primary`=?&quot;;
        $this-&gt;db-&gt;prepare($sql)-&gt;execute($params);
    }
    public function insert($data): int
    {
        $this-&gt;validate($data);

        $fields = '`'.implode(&quot;`,`&quot;, array_keys($data)).'`';
        $placeholders = str_repeat('?,', count($data) - 1) . '?';

        $sql = &quot;INSERT INTO `$this-&gt;table` ($fields) VALUES ($placeholders)&quot;;
        $this-&gt;db-&gt;prepare($sql)-&gt;execute(array_values($data));

        return $this-&gt;db-&gt;lastInsertId();

    }
    public function delete($id)
    {
        $sql = &quot;DELETE FROM `$this-&gt;table` WHERE `$this-&gt;primary`=?&quot;;
        $this-&gt;db-&gt;prepare($sql)-&gt;execute([$id]);
    }
    protected function validate($data)
    {
        $diff = array_diff(array_keys($data), $this-&gt;fields);
        if ($diff) {
            throw new \InvalidArgumentException(&quot;Unknown field(s): &quot;. implode($diff));
        }
    }
}

and children classes where related properties are set to operate certain tables:
class UserGateway extends BasicTableGateway {
    protected $table = 'users';
    protected $fields = ['email', 'password', 'name', 'birthday'];
}

which is then used like this
$data = [
    'email' =&gt; 'foo@bar.com',
    'password' =&gt; 'hash',
    'name' =&gt; 'Fooster',
];
$id = $userGateway-&gt;insert($data);
$user = $userGateway-&gt;read($id);
echo json_encode($user),PHP_EOL;

$userGateway-&gt;update(['name' =&gt; 'Wooster'], $id);
$user = $userGateway-&gt;read($id);
echo json_encode($user),PHP_EOL;

$users = $userGateway-&gt;list();
echo json_encode($users),PHP_EOL;

$userGateway-&gt;delete($id);

The code works all right for a simple CRUD but I'd like to know whether it can be improved or may be there are some critical faults I don't realize.
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.