
3
Adding view counter to bolt CMS
I might write about Bolt CMS separately later, but for now, let's focus on improvements.
Initially, the task was simple: add a view counter for articles. However, I also wanted to address several important issues:
- Prevent view count manipulation
- Filter out bots
- Keep it simple - without external services or additional systems like redis/memcached/etc.
Options
1. Simple Counter
Add a field to the record entity and increment it with each request to the article's detail page.
Minimal effort, but each update will increase the counter. By holding F5, one can artificially inflate the view count to any value.
2. Client-Side Protection
Track article views on the client side using cookies or localStorage. The counter increment request can be sent via JavaScript from the article page - this way we'll filter out views from bots/crawlers that don't fully render the page and don't execute JavaScript.
This requires a bit more effort, and simple F5 pressing won't increase views anymore, but clever users (why they would need this is a separate question) can disable cookies or clear localStorage and still generate views.
Besides clients, bots can do this too, so let's explore further.
3. IP-Based Protection
Track unique article views by IP address. Since we don't have a more suitable system than the database, we'll maintain a separate table where we store the IP address, record ID, and view date. These records can be periodically deleted so that opening an article after an hour/day/week adds a new view.
There are other options - using invisible captcha on pages to track views, getting view counts from external analytics via API, but they seem excessive, so we won't consider them here. We also won't discuss load optimization - personal blogs usually aren't highly loaded, and there's no real need to cache database queries.
Additionally, we can exclude bot visits - they usually specify their own User-Agent headers, which we can use to filter out such requests.
Overall, the goal of tracking views is to get the most accurate numbers without inflation.
I settled on this option, setting the period to one day - meaning an article view from one IP address is recorded only once per day.
Step 1. Adding the Views Field
Add js-library and css-file from cloud into our main template:
<!-- GLightbox CSS -->
<link href="https://cdn.jsdelivr.net/npm/glightbox/dist/css/glightbox.min.css" rel="stylesheet">
<!-- GLightbox JavaScript -->
<script src="https://cdn.jsdelivr.net/gh/mcstudios/glightbox/dist/js/glightbox.min.js"></script>
Bolt CMS is built on Symfony, and the following steps will be generally trivial for Symfony developers. While I'm quite familiar with Symfony from work, here we'll implement a super simple solution, comparable in complexity to my comment extension for bolt.
First, we add a views
field to the ContentType configuration. This is a simple numeric record counter.
In Bolt, this is done without migrations, just by adding a field to the configuration:
# config/bolt/contenttypes.yaml
posts:
name: Posts
singular_name: Post
fields:
# ... other fields ...
views:
type: number
mode: integer
default: 0
group: Meta
readonly: true
Step 2: Creating an Entity for View Storage
To track unique views, we create an ArticleView entity:
<?php
namespace App\Entity;
use Doctrine\ORM\Mapping as ORM;
/**
* @ORM\Entity
* @ORM\Table(name="article_views")
*/
class ArticleView
{
/**
* @var int
* @ORM\Id
* @ORM\GeneratedValue(strategy="AUTO")
* @ORM\Column(type="integer")
*/
protected $id;
/**
* @var string
* @ORM\Column(type="string", length=45)
*/
protected string $ip;
/**
* @var int
* @ORM\Column(name="content_id", type="integer")
*/
protected int $contentId;
/**
* @var \DateTime
* @ORM\Column(type="datetime")
*/
protected \DateTime $viewedAt;
public function __construct(string $ip, int $contentId)
{
$this->ip = $ip;
$this->contentId = $contentId;
$this->viewedAt = new \DateTime();
}
// ... getters ...
}
Step 3: Controller for View Counting
Create a controller that handles AJAX requests for incrementing the counter:
<?php
namespace App\Controller;
use App\Entity\ArticleView;
use Bolt\Entity\Content;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Annotation\Route;
class ViewCounterController extends AbstractController
{
protected EntityManagerInterface $em;
/**
* Common bot user agent keywords
*/
protected array $botKeywords = [
'bot', 'crawler', 'spider', 'slurp',
// ... other keywords ...
];
public function __construct(EntityManagerInterface $em)
{
$this->em = $em;
}
protected function isBot(string $userAgent): bool
{
$userAgent = strtolower($userAgent);
foreach ($this->botKeywords as $keyword) {
if (str_contains($userAgent, $keyword)) {
return true;
}
}
return false;
}
#[Route('/async/increment-views/{id}', name: 'increment_views', methods: ['POST'])]
public function incrementViews(Request $request, Content $content): JsonResponse
{
// Bot check
if ($this->isBot($request->headers->get('User-Agent', ''))) {
return new JsonResponse([
'views' => $content->getFieldValue('views')
]);
}
$ip = $request->getClientIp();
// Check for views from this IP in the last 24 hours
$viewRepository = $this->em->getRepository(ArticleView::class);
$existingView = $viewRepository->createQueryBuilder('v')
->where('v.ip = :ip')
->andWhere('v.contentId = :contentId')
->andWhere('v.viewedAt > :dayAgo')
->setParameters([
'ip' => $ip,
'contentId' => $content->getId(),
'dayAgo' => new \DateTime('-24 hours')
])
->getQuery()
->getOneOrNullResult();
if (!$existingView) {
// Record new view
$view = new ArticleView($ip, $content->getId());
$this->em->persist($view);
// Increment counter
$content->setFieldValue('views', (int)$content->getFieldValue('views') + 1);
$this->em->flush();
}
return new JsonResponse([
'views' => $content->getFieldValue('views')
]);
}
}
Step 4: JavaScript for Request Sending
Add JavaScript to the article page's Twig template to send an asynchronous request. Besides incrementing the counter, it also updates the value on the page if the user's view led to an increase in view count:
<script>
document.addEventListener('DOMContentLoaded', function() {
fetch('/async/increment-views/{{ record.id }}', {
method: 'POST',
headers: {
'X-Requested-With': 'XMLHttpRequest'
},
credentials: 'same-origin'
})
.then(response => response.json())
.then(data => {
if (data.views) {
document.querySelector('.view-counter').textContent = data.views;
}
});
});
</script>
Step 5: Displaying the Counter
Add the view count display to the template:
{# In detail view #}
<div style="color: darkgray; float: right; margin-left: 10px;">
{{ record.publishedAt|localdate(format='d.m.Y H:i') }}
<br>
<i class="fas fa-eye"></i> <span class="view-counter">{{ record.views|default(0) }}</span>
</div>
Step 6: Cleaning Up Old Records
Add a console command for cleaning up old records:
<?php
namespace App\Command;
use App\Entity\ArticleView;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
class CleanupViewsCommand extends Command
{
protected static $defaultName = 'app:cleanup:views';
protected static $defaultDescription = 'Remove old article views';
protected EntityManagerInterface $em;
public function __construct(EntityManagerInterface $em)
{
parent::__construct();
$this->em = $em;
}
protected function configure(): void
{
$this
->addOption(
'older-than',
null,
InputOption::VALUE_REQUIRED,
'Remove views older than this date (strtotime format)',
'-1 day'
)
->addOption(
'dry-run',
null,
InputOption::VALUE_NONE,
'Only show what would be deleted'
);
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$olderThan = new \DateTime($input->getOption('older-than'));
$isDryRun = $input->getOption('dry-run');
$qb = $this->em->createQueryBuilder();
$qb->select('v')
->from(ArticleView::class, 'v')
->where('v.viewedAt < :olderThan')
->setParameter('olderThan', $olderThan);
$views = $qb->getQuery()->getResult();
$count = count($views);
if ($count === 0) {
$io->success('No old views found to delete.');
return Command::SUCCESS;
}
if ($isDryRun) {
$io->note(sprintf('Would delete %d views older than %s', $count, $olderThan->format('Y-m-d H:i:s')));
return Command::SUCCESS;
}
$qb = $this->em->createQueryBuilder();
$qb->delete(ArticleView::class, 'v')
->where('v.viewedAt < :olderThan')
->setParameter('olderThan', $olderThan);
$deleted = $qb->getQuery()->execute();
$io->success(sprintf('Deleted %d views older than %s', $deleted, $olderThan->format('Y-m-d H:i:s')));
return Command::SUCCESS;
}
}
The command can be run manually:
# Delete records older than 1 day (default)
php bin/console app:cleanup:views
# Delete records older than a week
php bin/console app:cleanup:views --older-than="-7 days"
# See how many records would be deleted
php bin/console app:cleanup:views --dry-run
Or add it to cron for automatic cleanup:
0 0 * * * php bin/console app:cleanup:views
Final Solution
As a result, we've created a reliable view tracking system with the following advantages:
-
Protection Against Manipulation:
- One view per IP address per day
- Bot filtering by User-Agent
- View history storage for a specific period
-
Performance:
- Asynchronous view counting
- Regular cleanup of old records
-
Usability:
- Instant counter updates on the page
- Simple integration into existing templates
- Easy maintenance through console commands
Possible Improvements
The system can be improved in the future:
- Add counter caching to reduce database load
- Implement more sophisticated bot protection (fingerprinting, behavioral analysis)
- Add analytics or delay view counting based on page view time
No comments yet
-
Gift box with AI
The time has come when AI can not only respond in chats and be used in digital… -
Making images clickable on the website
I often insert high-quality images, but until now there wasn't a convenient way… -
Enabling runtime statistics collection in ESP32-arduino
This article will focus on the FreeRTOS functions vTaskGetRunTimeStats and vTas… -
Adding view counter to bolt CMS
Let's create a view counter for articles in Bolt CMS. We'll explore possible im… -
Hidden Disc Holder
Looking for a place to store your disc collection, and find shelf storage borin… -
Student48
For LSTU Computer Science graduates of 2014-2021, this name means a lot - let's…