Как реализовать массив подстановок для запроса в MySQL?
Внимание, а теперь правильный ответpublic function query($sql, $params = NULL){
        $sth = $this->dbh->prepare($sql);
        $sth->execute($params); // <----
        return $sth;
    }
Это базовая функция, которая должна использоваться при выполнении ВСЕХ запросов.
В том числе запросов на вставку, обновление и удаление, а также запросов, которые при всем желании не могут вернуть объект. Например$db->query("DELETE FROM t WHERE id=?", [$id]);
$count = $db->query("SELECT count(*) FROM t")->fetchColumn();
В принципе, этого уже достаточно, поскольку для получения списка объектов можно написать$list = $db->query("SELECT count(*) FROM t")->fetchAll(\PDO::FETCH_CLASS, "class");
Но если прям так очень хочется, то можно засунуть этот код в хелпер-функцию public function listObjects($sql, $class, $params){
        return $this->query($sql, $params)->fetchAll(\PDO::FETCH_CLASS, $class);
    }
Используя ...$arr вы не получите ключи в массиве. Поэтому почему бы не сделать третий агрумент обычным агрументом, и не передавать туда массив, который без изменений будет передаваться в execute?public function query($sql, $class, $params){ // $params = [":age"=>20]
        $sth = $this->dbh->prepare($sql);
        $res = $sth->execute($params); // <----
        if (false !== $res) {
            return $sth->fetchAll(\PDO::FETCH_CLASS, $class);
        }
        return [];
    }