Leaderboard
Popular Content
Showing content with the highest reputation on 07/12/2024 in all areas
-
Making ProcessWire stronger for full-stack web application development, allowing it to become an unassuming alternative to Laravel and Rails but from the origins as a CMS. ProcessWire is the perfect CMS (there's no doubt in my mind about that), and it's actually already quite good for web application development (both natively and with 3rd party modules), but with some enhancements to make it more "batteries included", enhancing page classes and some tooling, ProcessWire can have its feet in both the CMS and full-stack framework buckets in a way that's perhaps unique. I can elaborate on this further as that sounds a little too generic, but I've been developing a web application with PW for over 9 months (it's a very complicated project and it's replacing an existing, in-production system which makes it even more tricky and high-stakes) and when it's done I can share some ideas. This one enhancement alone moved the needle quite a bit in making ProcessWire more web application friendly.7 points
-
Fridays are always the days when I'm full-time on ProcessWire. Other days I may be doing client work, or ProcessWire, just depending on the week. This week it's been mostly client work. And I just learned that I'll have to be out of the office tomorrow (Friday) for a family commitment. So I'm writing this weekly update today instead, and just sent out Teppo's great ProcessWire Weekly newsletter with ProMailer today (usually it gets sent Friday). Because of this change in schedule, I don't have much new to report just yet. Instead, I wanted to start talking a little about future plans, so here's a few ideas. I think we should get another main/master version out, perhaps by September. Following that, I thought we should focus on ProcessWire 3.1, or maybe it's time for version 4. What would be in this next major version of PW? For starters, we'll finally drop support for older PHP versions and start taking advantage of all that's been added new newer PHP versions (8+). This will involve lots of updates to the ProcessWire core code base, while remaining 100% API compatible with PW 3.x. I thought that would be a good starting point into our next major version at least. In addition, we'll likely be trimming some things out of the core and into separate non-core modules, such as FileCompiler, CKEditor, the two legacy admin themes, and a few rarely used Textformatter modules. Most likely we'll also have an overhaul of this website and some nice improvements to our primary (Uikit) admin theme to accompany the next major version as well. There will be plenty more too, but this is what I've been thinking about so far. Your feedback is welcome. Thanks for reading and have a great weekend!5 points
-
Thanks for this topic @ryan! Great to have a chance to look into the future and maybe even influence it a bit. I agree with @Jonathan Lahijani, but we surely need to expand on that. I can understand his point quite well. ProcessWire taught me be a (somewhat) ambitious developer, that is ready for bigger projects. And some of those grew big enough I started seeing the limitations of PW. And though I think that PW might be not the best choice for some projects (bigger web apps, as Ryan himself pointed out before), it can have its sweet spot in between regular CMS and web frameworks like Laravel. I will try to point some of the ways I see to improve PW to move further into that sweet spot. For now I can throw in this one: A better way to build custom user admin area. Reusing existing admin сomponents, but without the need to hide lots of stuff with permissions and hooks. So we could build something like /user-admin with limited functionality, but still using page edit module.5 points
-
I think the most talked about feature is an asset manager. The drilling down of file and imagefields so that you can choose assets from a global library that have already been uploaded somewhere, instead of having to upload assets multiple times. Actually, the approach using references is the best I've come across so far. The data remains where it was originally uploaded, but is only referenced in a file/image field on another page.4 points
-
I'd love to see an "officially supported and recommended (and even documented) way" of initializing custom page class objects, discussed and requested by us over the years: https://github.com/processwire/processwire-requests/issues/456 https://processwire.com/talk/topic/30138-page-classes-diving-into-one-of-processwires-best-features/?do=findComment&comment=242737 https://processwire.com/talk/topic/25342-custom-classes-for-page-objects-the-discussion/3 points
-
Actually that post is where I got to learn that we can use loaded() for initialisation. It is great that you discussed this in such detail there, especially the drawbacks. I thought, since the OP seems to have a lot of expertise in using page classes, I'd ask him if he had found a more elegant way than using loaded(). And putting everything together that I have learned about page classes, I will avoid initialisation of props using loaded() with DefaultPage going on from here ?2 points
-
Version 2.2.5 is out! It includes 2 bug fixes and 1 new feature: Inputfield dependencies! Most of you will know Inputfield dependencies from the ProcessWire backend. This is the same feature for FrontendForms, but it relies on a different code than the one used in the backend. I have found an interesting script on Github written by Ali Khallad and I have implemented it into FrontendForms. If you are interested in: Here is the the code on Github. Short description of Inputfield dependencies: Let's say you have two fields in your form: a number input field (field 1) and a text input field (field 2). Field 2 should only be visible if the value "1" is selected inside field 1. Otherwise, field 2 should be hidden. The input field dependencies allow you to add the condition directly to field 2 without having to write a line of JavaScript. $field2->showIf([ 'name' => 'field1', 'operator' => 'is', 'value' => '1' ]); Here is an example in action: I have written a very detailed documentation and added a lot of examples. You will find all about the new feature here. As always: This is a brandnew feature, so keep an eye if everything works as expected. Report issues or suggestions here or directly on Github. Before using it on live sites make a backup.? Jürgen2 points
-
Overview The TextformatterTokens module allows other modules to register tokens and replace them with actual values. Installation Enable the TextformatterTokens module. Enable the SiteTokens module. Enable the TextformatterTokens text formatter for a field. Usage Use tokens provided by the SiteTokens module inside a field that has the TextformatterTokens text formatter enabled. Save and display your page. The tokens should be replaced with the actual content. Example Tokens [site:name] [page id=123] where id is the ID of the page you want to embed its body field. Debugging You can view logs under your ProcessWire admin dashboard at Setup > Logs > tokens. Writing own tokens To create custom tokens, refer to the SiteTokens.module file for an example implementation.1 point
-
Page classes are an outstanding feature of ProcessWire and probably ranks among my favorites overall (and that's really saying something with ProcessWire). I don't remember how I lived without them, well I do, but I don't like to think about it. I wrote a response to a question asking how ProcessWire can help transition from procedural code to OOP and in the process (pardon the pun) of answering, I realized how much I've come to use, if not outright rely on, page classes in every project. I wanted to compile a few more thoughts and examples here because there may be devs who are finding this feature for the first time, and besides, why not go down the rabbit hole? Page classes have been around since ProcessWire 3.0.152 and if you're not familiar this will all make a lot more sense if you do a quick read of the feature announcement by Ryan here because this post assumes familiarity. We're also going to pick up the pace between the examples below compared to my comment in the link above. Here are a few goals that I have that page classes put solutions for within reach. You may have some of your own and I'd love to hear about those as well. Keep templates clean by restricting logic to flow operations- if statements and loops. Work with data in context, accessing data and fields closer to their source. Create "universal" methods available in every template, but also have scoped methods per-template Increase the scalability of a project, growth with limited increases in complexity. Stay DRY Embrace and extend the power of OOP in ProcessWire's DNA This seems like quite a list for one feature to handle, but rest assured, this is an example of how much power comes with using page classes. I'll continue and build on the blog example from my linked response above, and you can "follow along" with the Blog module since the templates mentioned are present out of the box. Some of the examples below are taken from production code, but please excuse any errors that I may have introduced by accident and I'm happy to update this post with corrections. Some of these things can be done other ways but they're just to illustrate, replace with your ideas and think of times that this would be useful. First up, what are some features and behaviors that everyone needs on every project? What are some universal methods that would be great to have everywhere? Let's start out by creating a "base" page class called DefaultPage. Going forward, page classes will extend this class instead of Page and benefit from having access to universal methods and properties. EDIT: I initially wrote this using BasePage rather than DefaultPage for this class name as described by the custom page class writeup by Ryan in the article above. I've changed this to now use the correct DefaultPage class name. <?php namespace ProcessWire; // /site/classes/DefaultPage.php class DefaultPage extends Page { /** * Returns all top level pages for the main navigation. */ public function navigationPages(): PageArray { $pages = wire('pages'); $selector = 'parent=1'; $excludedIds = $pages->get('template=website_settings') ->nav_main_excluded_pages ->explode('id'); // Check for excluded pages from nav defined in the "Website Settings" page count($excludedIds) && $selector .= ',id!=' . implode('|', $excludedIds); $homePage = $pages->get('id=1'); $topLevelPages = $pages->find($selector); $topLevelPages->prepend($homePage); return $topLevelPages; } } All your base page are belong to us. Right off the bat we've managed to pull complex logic usually in templates and kept our markup clean. This isn't for DRY, it's to store logic out of templates. This simple example only illustrates working with a top level page nav. We can start to appreciate the simplicity when considering how much more the navigation may call for in the future. A navigationChildren method that also accounts for excluded pages is a prime example. In our markup: <!-- /site/templates/components/site_nav.php --> <nav> <ul> <?php foreach ($page->navigationPages() as $navPage): ?> <li> <a href="<?= $navPage->url; ?>"><?= $navPage->title; ?></a> </li> <?php endforeach ?> </ul> </nav> Next up, our settings page has newsletter signup fields where users can copy/paste form embed code from Mailchimp. Our website has a "settings" page where global values and fields can be edited and maintained There are multiple textarea fields on the settings page that can each contain a different mailchimp embed code There is an embed select field that can be added to templates. The values are textarea field names so an embed can be chosen by the user. An embed select field has been added to the settings page which allows for choosing a default embed code if one isn't selected when editing a page Embedded forms should be available anywhere on any template <?php namespace ProcessWire; // /site/classes/DefaultPage.php class DefaultPage extends Page { // ...Other DefaultPage methods /** * Renders either a selected form embed, fallback to default selected form */ public function renderEmailSignup(): ?string { $settingsPage = wire('pages')->get('website_settings'); $embedField = $this->signup_embed_select ?: $settingsPage->default_email_embed; return $settingsPage->$embedField; } } Excellent. We've got some solid logic that otherwise wouldn't have a great home to live in without page classes. We can also modify this one method if there are additional options or complexity added to the admin pages in the future. Wherever we use renderEmailSignup, the return value will be either the selected or default form embed code. Let's create a folder called "components" in our templates directory that will hold standalone reusable markup we can use wherever needed, here's our newsletter signup component: <!-- /site/templates/components/newsletter_signup.php --> <div class="newsletter-signup"> <?= $page->renderEmailSignup(); ?> </div> Great! We've kept logic out of our code. We can render the field, and we can account for an empty value with a fallback. Unfortunately this is pretty limited in that it only handles a specific field, and it's implementation isn't as flexible as a component should be. We can fix that, and here are some new requirements to boot: Some templates may have multiple embed select fields which may or may not have a value Each embed select field is now paired up with a text field to add some content that appears with each form embed, think "Sign up for our newsletter today!" We want to render our mailchimp form embeds using the same component file, but handle different fields Sounds like a tall order, but it's a challenge easily overcome with page classes. We're going to introduce a new method called renderComponent that will be global, reusable, and very flexible. We're also going to make use of another very great ProcessWire feature to do it. Back to our DefaultPage class: <?php namespace ProcessWire; // /site/classes/DefaultPage.php class DefaultPage extends Page { // ...Other DefaultPage methods /** * Renders a file located in /site/templates/components/ with optional variables */ final public function renderComponent(string $file, ...$variables): string { $componentsPath = wire('config')->paths->templates . 'components/'; return wire('files')->render($file, $variables, ['defaultPath' => $componentsPath]); } /** * Renders either a selected form embed, fallback to default selected form */ public function renderEmailSignup(?string $embedSelectField = null): ?string { $settingsPage = wire('pages')->get('website_settings'); $embedField = $this->$embedSelectField ?: $settingsPage->default_email_embed; return $settingsPage->$embedField; } } We've done a couple of things here. We've updated renderEmailSignup to accept a field name, so now we're flexible on exactly which field select we'd like to check for a value on before falling back to default. We've also created a renderComponent method that is going to be super useful throughout the rest of our ProcessWire application. Our renderComponent receives a file name located in the components directory and any number of named parameters. You could change the $variables parameter to an array if you'd like, but I'm a big fan of the great features we have now in PHP 8+. Here's our refactored component file: <!-- /site/templates/components/newsletter_signup.php --> <?php if ($embedField): ?> <div class="newsletter-signup"> <?php if ($text): ?> <h2><?= $text; ?></h2> <?php endif ?> <?= $page->renderEmailSignup($embedField); ?> </div> <?php endif ?> And let's hop over to our (abbreviated) home page template: <body> <!-- ...Sections full of great content --> <section class="signup-call-to-action"> <?= $page->renderComponent('newsletter_signup.php', embedField: $page->embed_select, text: $page->text_1); ?> </section> <!-- ...Your awesome template design --> <section class="page-end-call-to-action"> <?= $page->renderComponent('newsletter_signup.php', embedField: $page->embed_select_2, text: $page->text_2); ?> </section> <!-- Your footer here --> </body> I don't know about you, but this is looking really good to me. The number of things we've accomplished while having written so little code is remarkable: Because we used wire('files')->render(), the entire ProcessWire API is available within the component, so now our renderEmailSignup method is too. The variadic function parameters (or array if preferred) let us pass an arbitrary number of variables to any component file, unrestricted future flexibility Variables are scoped to each component! There's no reference to template fields in our component that could break if changes are made No more PHP includes, we don't have to juggle paths or constantly repeat them in our code, nor rely on declaring variables before including a file. ProcessWire will throw an exception if we try to render a file that does not exist which makes locating issues very easy We'll also see an exception if we try to reference a variable in our component that wasn't passed which can also help troubleshooting. Notice that the renderComponent is final. We want that behavior to remain consistent everywhere we use it and not overwritten either intentionally or by accident on our inheriting page classes. We want to eliminate any confusion between templates by knowing it will always do the same thing the same way. We can explore other uses too, perhaps a renderPartial for files in /site/templates/partials where we store files like site_header.php. As mentioned above however, if a variable is expected in the rendered file but not included in our render method, we'll see an exception. Let's use site_header.php as an example because we're sure to run into situations where variables may or may not exist: <?php namespace ProcessWire; // /site/templates/partials/site_header.php ?> <!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title><?= $page->title; ?></title> <meta name="description" content="<?= $metaDescription ?? null; ?>"> <?php if ($includeAnalytics ?? true): ?> <script> // All that Google Analytics jazz </script> <?php endif ?> </head> <body class="<?= $bodyClasses ?? null; ?>"> <header> <?= $page->renderComponent('site_nav.php', includeEmailButton: true); ?> </header> Problem: solved. By using nullsafe ?? operators, we can call $page->renderPartial('site_header', description: 'The ultimate Spice Girls fan page.'); and never get errors for variables that may not be included when calling renderComponent, such as includeAnalytics, which now also has a default value of 'true'. Nice. We haven't even gotten to our actual page classes yet... Our templates are about to receive superpowers. Let's take our blog to the next level. In my comment on the other thread, I created a specific example of adding a readTime method to our blog posts, let's go one level higher to our blog.php template. We'll populate some methods up front and then talk about what we've done: <?php namespace ProcessWire; // /site/classes/BlogPage.php class BlogPage extends DefaultPage { /** * Get latest blog posts, optionally with/without pinned post, optionally in blog category */ public function latestPosts( int $limit = 3, bool $includePinnedPost = true, ?int $categoryId = null ): PageArray { $selector = 'template=blog-post'; if ($categoryId) { $selector .= ",blog_category={$categoryId}"; } $posts = wire('pages')->get($selector); if ($includePinnedPost) { $pinnedPost = $this->getPinnedPost(); if ($pinnedPost) { $posts->remove($pinnedPost); $posts->prepend($pinnedPost); } } return $posts->slice(0, $limit); } /** * Gets an optional pinned post if set/chosen * pin_blog_post A checkbox to indicate whether a post should be pinned * pinned_post A InputfieldPage field to choose a blog post */ public function getPinnedPost(): ?Page { if (!$this->pin_blog_post) { return null; } return wire('pages')->get($this->pinned_post); } } On our main blog page a user has the ability to choose whether a blog post is "pinned". A pinned post will always remain the first post anywhere a list of posts is needed, something like a big company announcement that the client wants to keep visible. These two methods alone have given us awesome abilities. For our main blog page, when someone visits our blog page, the most recent or pinned post is presented at the top, followed by the next two most recent posts, followed by two rows of 3 posts, for a total of 9. Let's assume that we've already created the BlogPostPage.php with the readTime method from my previous example. Here's our blog.php template <?php namespace ProcessWire; echo $page->renderPartial( 'site_header.php', bodyClasses: 'blog-page', metaDescription: $page->blog_description ); $posts = $page->latestPosts(9); $firstPost = $blogPosts->first(); ?> <section class="main-post"> <article> <img src="<?= $firstPost->blog_image->url; ?>" alt="<?= $firstPost->blog_image->description; ?>"> <h1><?= $firstPost->title; ?></h1> <?= $firstPost->summary; ?> <span><?= $firstPost->readTime(); ?></span> <a href="<?= $firstPost->url; ?>">Read More</a> </article> </section> <section class="recent-Posts"> <?= $page->renderComponent('blog_preview_card.php', blogPost: $posts->get(1)); ?> <?= $page->renderComponent('blog_preview_card.php', blogPost: $posts->get(2)); ?> </section> <section class="past-posts"> <?php foreach ($posts->slice(3, 6) as $post): ?> <?= $page->renderComponent('blog-preview_card.php', blogPost: $post); ?> <?php endforeach ?> </section> <?= $page->renderPartial('site_footer.php'); ?> So first off, we've really started to use our renderComponents method and component files. We also implemented a renderPartial as speculated upon above. Each does a similar thing, but having separate methods makes everything clear, handles paths, but still has a similar interface when calling them. A big thing to notice here is that at no point have we added any markup to our page classes, and no business logic to our templates. If we need to find anything, we know where to look just by glancing at the template. Ultimate maintainability. Here's our blog_preview_card.php component: <?php namespace ProcessWire; ?> <!-- /site/templates/components/blog_preview_card.php --> <article class="blog-preview-card card"> <img src="<?= $blogPost->blog_image->url; ?>" alt="<?= $blogPost->blog_image->description; ?>"> <h2><?= $blogPost->title; ?></h2> <div class="blog-summary"> <?= $blogPost->blog_summary; ?> </div> <span class="blog-read-time"><?= $blogPost->readTime(); ?></span> <a href="<?= $blogPost->url; ?>">Read More</a> </article> I am liking how well this is working out! Page classes have done a ton of heavy lifting here: We're using our renderComponent method to it's maximum potential and it's payed off in spades Our template couldn't be cleaner or more easily maintainable BlogPostPage.php has taken care of all of our needs as far as delivering the PageArray of posts and all our template does is output the data as needed Our "card" component will render the same thing everywhere and we can update how that looks globally with changes to one file If you don't think this can get more awesome, or think this post is already too long, I have bad news for you and you should stop reading now. Still here? Let's create a BlogPostPage class and add a method: <?php namespace ProcessWire; // /site/classes/BlogPostPage.php class BlogPostPage extends DefaultPage { // ... Other page methods, like readTime() public function relatedPosts(int $limit = 3): PageArray { return wire('pages')->get('template=blog')->latestPosts( limit: $limit, includePinnedPost: false, categoryId: $this->blog_category?->id ); } } The BlogPage::latestPosts method is really flexing it's muscle here. We've used it in two different places for different purposes but requesting similar data, blog posts. If you noticed, we're also specifying a category for this blog since we have a page select field that references a blog category. That was a parameter that we included back in our BlogPage::latestPosts() method. So, blog posts have a "You might also be interested in..." section with posts in the same category as the that one that a visitor has just read. With page classes this couldn't be easier to add, so let's use the relatedPosts method we just created in BlogPostPage.php in our blog-post.php template: <?php namespace ProcessWire; // /site/templates/blog-post.php echo $page->renderPartial( 'site_header.php', bodyClasses: 'blog-post', metaDescription: $sanitizer->truncate($page->blog_summary, 160) ); ?> <!-- The page hero, blog content, stuff, etc. --> <section class="related-posts"> <?php foreach ($page->relatedPosts() as $post): ?> <?= $page->renderComponent('blog_preview_card.php', blogPost: $post); ?> <?php endforeach ?> </section> <?= $page->renderPartial('site_footer.php'); ?> We've just added a related posts feature in *checks stopwatch* seconds. This project is going so fast that you can already hear the crack of the cold beer after a job well done. One more example, and I'll let you decide if this is too much, or feels just right. We want a blog feed on our home page. So before we go to the home.php template, let's do what we do (surprise, it's a page class): <?php namespace ProcessWire; // /site/classes/HomePage.php class HomePage extends DefaultPage { // Other HomePage methods... public function blogFeed(int $limit = 3): PageArray { return wire('pages')->get('template=blog')->latestPosts(limit: $limit); } } At first glance, this might seem like a bit much. Why create a method in our page class that is so short and simple? We could just call $pages->get('template=blog')->latestPosts(3) inside our template and get away with it just fine. Here's why I think it's worth creating a dedicated page class method: This creates yet another example of predictability between templates It promotes thie philosophy I like of page classes talking to page classes It's likely that we'll have other more complex methods in the HomePage class, keeping them together feels right and helps with uniformity- we always know where to look when seeing custom methods If we need to make a change to the BlogPage::latestPosts() method that affect how other page classes call that method, we don't have to root around in our templates to make any changes. It's pretty nice to see that thus far we have only ever referenced the $page object in our templates! There aren't any calls to $pages because our template is really about doing one thing- rendering the current page without caring about other pages. That's not to say that we can't, or shouldn't, use other ProcessWire objects in our templates, but it's still impressive how much we've been able to scope the data that we're working with in each template. I truly love the page classes feature in ProcessWire and even though this is a deeper dive than my other post, this really is still just scratching the surface because we can imagine having more complex behavior and other benefits. Here are some from my experience: Handling AJAX calls to the same page. Choosing how and what type of content is returned when the page is loaded is really nice. This can be done with hooks, but I really like page classes handling things like this in-context. Working with custom sessions between one or more pages. Creating a trait that shares session behavior between page classes is a great way to extend functionality while keeping it scoped. Adding external API calls and whatever complexity that may entail Provides the ability to add significant complexity to our templates in the admin yet little to no additional complexity in template files. The basic "pinned post" and form embed features are just the start As you can imagine, this makes replicating code you use often between projects trivial Page classes provide a home for logic that otherwise would be stuck in a template, or relegated to a messy functions.php file. No more functions.php ever. We met every single goal I listed here at the beginning Every single template thus far only uses if statements and loops DefaultPage could have a "bootApplication" method if needed that does things for every page and is called in ready.php. This is also a great way to create a "bootable" method in all of your page classes where bootApplication could call a bootPage method in your page classes if it exists. Thanks for coming to my TED talk. Hope you find this useful! If you have any questions, corrections, use cases, or things to add, the comments below are open and I'd love to hear them!1 point
-
@bernhard Your modules have so many features I'm still getting to know them all haha. Awesome, will keep that in mind as well. I think I'm going to need these features a lot more on the project I'm getting started on.1 point
-
As I know you are using RockMigrations / RockPageBuilder have you seen https://www.baumrock.com/en/processwire/modules/rockmigrations/docs/magicpages/ ? It will let you define hooks in an init() or ready() method in your custom page class. There you can add hooks and RM will make sure it will only trigger init/ready once for each pageclass, which is one culprit when using loaded() as it will get triggered multiple times and potentially add hooks more often than once. Also it has shortcuts for often needed hooks, so for example you can hook the backend page edit form like this: public function editFormContent($form) { $form->add([ 'type' => 'markup', 'label' => 'foo', 'value' => 'bar', ]); }1 point
-
I meant to mention that I didn't know about that method before your comment. Now that I know about it though I'll probably end up using it. So thank you for the tip! There was one instance where it may have come in handy. I created a PageClass method that analyzes the current page request to determine if it was an AJAX request for a specific purpose, but decided to just call $page->whateverMethdName() at the top of the template before anything rendered. The loaded() method is the way I'll go with that next time. I learned something new today ? Bookmarking this. Keeping hooks closer to the location/context that makes sense is a good idea.1 point
-
I use a mixture of calls to run various initializing code, depending on what to initialize, e.g., copied from a Product page's custom class: /* * Called when the page is requested on the frontend. * Called from _init.php via include_once(config()->paths->templates . 'path/to/this/_init_once.php'); * so that it only runs once per request: * page()->requested(); */ public function requested() { $this->request_ws = "\ProcessWire\RqtProduct"; parent::requested(); } /* * When initialization of object properties is required right from the beginning. */ function __construct(Template $tpl = null) { parent::__construct($tpl); $this->factsAy = new Arrayy([]); } public function ___loaded() { parent::___loaded(); // Either building a structured array of all product data and caching it in a variable or reading that from memory. if (empty($this->facts)) { $this->encodeFacts(); } else { $this->factsAy = Arrayy::createFromJson($this->facts); } } /** * Product page specific hooks. This method is called from site/init.php from my custom loop * that runs for each frontend template based page. * if (in_array($templateName, config()->noneFrontendTemplates)) { * if ($templateName === "user") call_user_func_array("{$namespace}{$className}::initiate", []); // UserPage needs special treatment. * continue; * } * if (class_exists($namespace . $className)) call_user_func_array("{$namespace}{$className}::initiate", []); // Page classes register hooks in their initiate() method this way. */ public static function initiate() { parent::initiate(); wire()->addHookBefore("Pages::saveReady(template=product)", function ($event) { ... more hooks go here ....1 point
-
How to initialize a "custom page" object has also been discussed here before: https://processwire.com/talk/topic/25342-custom-classes-for-page-objects-the-discussion/ and is also discussed in this request at Github: https://github.com/processwire/processwire-requests/issues/456 I'd love to see an "officially supported and recommended way" of doing this.1 point
-
This was most likely what caused that behaviour. So yeah, we have to be very mindful there. Guess I needed to learn that the hard way ? Great suggestions here. And thanks for your thoughts on properties in page classes and the very well structured example you give. There might be a good reason to avoid them, since they add overhead to Page loading. And I haven't found other methods like init or ready that are suitable for attaching those properties. Since my main goal with them was to have sensibly named props that intellisense can discover, I might as well stick with methods. After having worked for some time with implementing those props via the loaded() method in the past, nowadays I avoid them for the reasons mentioned.1 point
-
Well, funny story, I have no reasoning. I just misremembered between reading that and hacking out this post. Jumping between projects in and outside of ProcessWire didn't help haha. I've used DefaultPage in the past, it was just an oversight this time around. Ryan's is of course the correct way. I generally always have a custom page class for each of my templates so I've never had an issue where DefaultPage was loaded on it's own. Appreciate you mentioning it, I've updated the post to use DefaultPage. My first reaction is that unexpected behaviors may indicate that there may be too much abstraction. My DefaultPage classes have traditionally been lightweight and stick to only containing what can be reliably counted on as valuable across all pages. One of the criticisms of OOP is that classes and inheritance can make for brittle code so it's something that I try to be mindful of. It may also be good to try an keep anything that executes automatically or hooks into the Page lifecycle to a minimum (maybe none at all) in DefaultPage since it spans the entire application. I use names like these myself for the same reason. Really helps keep a sane number of fields and reduce redundancy. I haven't taken this approach to reassigning values to properties like this but it's an interesting idea. I reviewed the code for the Page class and there is a good amount of logic in Page::set() that can be useful when working with values but may not be needed within the context of a page class. At first blush though I would just assign the value of the field to an instance variable directly rather than call Page::set() because at time of authorship- you know what field you'll be working with, it's data type, and intended usage within the context of that template/page. <?php public $introTitle; public $introText; // set custom properties in loaded public function loaded() { // load custom properties only for allowed statuses if (!empty(array_intersect(array_keys($this->status(true)), self::excludeStatuses))) return; $this->introTitle = $this->text; $this->introText = nl2br($this->textarea); } I may be missing the added value of using Page::set() in your use case though. My other thought though is that if these fields have consistent and wide enough usage through all templates, enough to reassign them to alternate property names, they might be a candidate for dedicated fields rather than repurposing generalized fields. Both introTitle and introText seem like they have wide enough usage, so I would create those fields and skip any extra logic needed here in DefaultPage. I actually haven't set a lot of instance variables in page classes. I've focused on methods that can abstract logic out of templates, or perform computationally expensive operations only on demand. Here's a DefaultPage from a production site: <?php namespace ProcessWire; use Ramsey\Uuid\Uuid as Uuid; use \DateTime; class DefaultPage extends Page { /** * Paths for rendering methods */ const COMPONENTS_PATH = '/../templates/components/'; const FORMS_PATH = '/../templates/components/forms/'; const PARTIALS_PATH = '/../templates/partials/'; const SVG_PATH = '/../templates/img/svg/'; /** * Stub method that may be overridded by inheriting classes where needed */ public function renderStructuredData(): ?string { return null; } /** * Outputs full path and filename for CSS files */ public function css(string $file): string { return wire('config')->urls->templates . "dist/styles/{$file}"; } /** * Outputs full path and filename for JavaScript files */ public function js(string $file): string { return wire('config')->urls->templates . "dist/scripts/{$file}"; } /** * Returns contents of an SVG file with unique IDs for title/description attributes for accessibility */ final public function renderSvg(string $file, array $variables = []): string { $variables = array_merge($variables, [ '{ARIA_TITLE_ID}' => Uuid::uuid4()->toString(), '{ARIA_DESC_ID}' => Uuid::uuid4()->toString(), '{CLASSES}' => $variables['classes'] ?? null, ]); return strtr(file_get_contents(__DIR__ . self::SVG_PATH . $file), $variables); } /** * Renders a file in /templates/partials/ */ final public function renderPartial(string $file, array $variables = []): string { return wire('files')->render($file, $variables, [ 'defaultPath' => __DIR__ . self::PARTIALS_PATH ]); } /** * Renders file located in templates/components/ */ final public function renderComponent(string $file, array $variables = []): string { return wire('files')->render($file, $variables, [ 'defaultPath' => __DIR__ . self::COMPONENTS_PATH ]); } /** * Renders a form */ final public function renderForm(string $file, array $variables = []): string { return wire('files')->render($file, $variables, [ 'defaultPath' => __DIR__ . self::FORMS_PATH ]); } /** * Gets the time period of the day based on the hour */ final public function getPeriodOfDay(): string { $hour = (new DateTime())->format('H'); switch (true) { case $hour < 12: return 'morning'; case $hour >= 12 && $hour <= 17: return 'afternoon'; default: return 'evening'; } } /** * Returns all top level pages for the main navigation */ final public function topLevelPages(): PageArray { $pages = wire('pages'); $selector = 'parent=1'; $excludedIds = $pages->get('template=admin_website_settings') ->nav_main_excluded_pages ->explode('id'); // Check for excluded pages from nav defined in the "Website Settings" page count($excludedIds) && $selector .= ',id!=' . implode('|', $excludedIds); $homePage = $pages->get(1); $topLevelPages = $pages->find($selector); $topLevelPages->prepend($homePage); return $topLevelPages; } } All of the other magic goes into traits or inheriting page classes. My soft guidelines when it comes to code organization is: Save abstraction until something is repeated 3 times, consistency should be maintained, or complexity warrant it for the sake of the surrounding code. Abstract to a trait or enum that can be selectively used where needed. Consider abstracting to a parent class last, there has to be a case for why it should be shared between all classes because it's more than ubiquity, it comes down to rock solid shared behavior and purpose1 point
-
What a great writeup! Thank you for putting your time into this. It could be part of the official docs for page classes. I would be interested in your reasoning behind naming the base class BasePage. In the docs that you linked, Ryan suggests DefaultPage. In https://github.com/processwire/processwire/blob/3cc76cc886a49313b4bfb9a1a904bd88d11b7cb7/wire/config.php#L202 it says: So are you using BasePage to not have every Page be based automatically on DefaultPage? I'm asking this, because I am using the DefaultPage class quite a lot and in some cases it caused some minor unexpected behaviours because the class is used for every Page. Would love to see how you implement properties in your base page class. Specifically how and where do you assign them? I utilize the loaded() method to assign those properties and found that it added lots of overhead because loaded() is called for every page that is loaded in a PW lifecycle when using it on the DefaultPage class. And that seemed a bit expensive to me, especially if construction of some properties requires more complex and expensive logic. Here's an example on how I implement custom properties on runtime class DefaultPage extends Page { // trait that provides methods for responsive images use ResponsiveImages; // define statuses that should be excluded from loadingcustom properties const excludeStatuses = array( Page::statusTrash, Page::statusUnpublished, Page::statusDraft, ); public $introTitle; public $introText; // set custom properties in loaded public function loaded() { // load custom properties only for allowed statuses if (!empty(array_intersect(array_keys($this->status(true)), self::excludeStatuses))) return; $this->set('introTitle', $this->text); $this->set('introText', nl2br($this->textarea)); } } I found that loaded() is called for every page regardless of their status. So I prevent loaded() from assigning properties for some statuses where the properties are not needed. Why I want to have custom properties in the first place? Almost all fields in my PW installations get very generic names like text, textarea, rte, image, text2 etc. I am using those fields in different templates for different purposes. Custom properties are a nice way to access those generic fields in a more meaningful manner and have them available in intellisense.1 point
-
I would love to see some kind of a bare bones of e-commerce features in the core, that could help to build a basic small shop directly in PW, without the need of integrating with third party e-commerce systems like Shopify etc. Just thinking out loud ? Have a great weekend!1 point
-
@FireWire this was masterful. Just wow. You, Ryan, ProcessWire, and the entire community is awesome.1 point
-
Hey there! Here’s an alternative approach to setting up a PHP development environment. It's neither better nor worse than others, just different. I've been using this method for years, and it has served me flawlessly. So, I decided to share it with you. Enjoy! 0) Overview The idea here is to keep my system neat and tidy. Since I use one laptop for everything, I like to keep all my development tools separate so everything stays organized. The main tool I use is a virtual machine, specifically VirtualBox. Another significant reason I use a VM is that it lets me mirror my development setup exactly as it will be on the production server. This way, I know I’m developing with the same PHP/APACHE/MYSQL versions that I'll use in production. Of course, this only works when I have full control over the production server. 1) Installing VirtualBox Let's start by installing VirtualBox. You’ll also need to install the Guest Additions ISO, but we can do that later. 2) Choosing the Guest OS Since all my production servers run Linux, I install the same OS on the guest VM. Here are some tips for installing your VM guest: To replicate the production server, I choose a minimal installation, avoiding window managers, desktop systems, or anything that wouldn’t be installed on production. You can configure your LAMP stack later, but enable your SSH server now as you will need it later. 3) Configuring the VM After the installation is done, let's configure your VM. These configurations are specific to my hardware and the fact that I am using Linux as the guest OS. With my machine off, this is what I have: Select the Port Forwarding button to configure rules for accessing services from your guest OS on your host OS. Note: '/home/daniel/Public/WWW' is the host OS path containing the PHP files. 'Folder Name' is a label used for mounting this directory. 4) Running the VM in Headless Mode To minimize resource usage and keep the VM running in the background, I use headless mode, meaning no GUI (although I can start it if needed). This is all about command line! Here’s how to set it up for Linux, Windows, and macOS. Linux I achieved this with two bash aliases on my Linux host machine. Add the following alias to your `~/.bash_profile` or `~/.bashrc` file: alias headless='VBoxManage startvm LAMP --type headless' alias poweroff='VBoxManage controlvm LAMP poweroff' After adding the aliases, run `source ~/.bash_profile` or `source ~/.bashrc` to apply the changes. Windows Create two batch files, one for starting the VM in headless mode and another for powering it off. headless.bat @echo off VBoxManage startvm LAMP --type headless poweroff.bat @echo off VBoxManage controlvm LAMP poweroff macOS Add the following aliases to your `~/.bash_profile` or `~/.zshrc` file: alias headless='VBoxManage startvm LAMP --type headless' alias poweroff='VBoxManage controlvm LAMP poweroff' After adding the aliases, run `source ~/.bash_profile` or `source ~/.zshrc` to apply the changes. 5) Accessing the VM via SSH and Installing Guest Additions Without a GUI, the way to access the VM is via SSH. Ensure you have SSH installed on your host OS. Here’s how to set it up for Linux, Windows, and macOS. Linux I have another alias for this: alias go='ssh -p2222 YOURHOST@127.0.0.1' Windows Create a batch file for SSH access. go.bat @echo off ssh -p2222 YOURHOST@127.0.0.1 macOS Add the following alias to your `~/.bash_profile` or `~/.zshrc` file: alias go='ssh -p2222 YOURHOST@127.0.0.1' After adding the alias, run `source ~/.bash_profile` or `source ~/.zshrc` to apply the changes. Install your Guest Additions The installation process may differ depending on your host OS, so it's best to do a quick Google search and follow the documentation. It's not hard to do, it just involves a few steps. You need to have the Guest Additions installed before continuing to the next step. 6) Setting Up the Guest Machine Once you’re in your guest machine via SSH, it's time to set it up. The actual setup will depend on your needs, so I’ll just show you how to get your files served from your host system. The following setup happens on your guest OS: 6.1) Mounting Directories From your `/etc/fstab`, add something like: # /etc/fstab: static file system information. # # <file system> <mount point> <type> <options> <dump> <pass> www /var/www/ vboxsf auto,rw,uid=33,gid=33 0 0 `www` is the same name as in the 'Folder Name' field shown in the picture above. `/var/www` is where I want `www` to be mounted. It could be `/srv`, `/mnt`, or any other path you prefer. This is the type of file system. vboxsf stands for VirtualBox Shared Folder, a type used to mount folders shared between the host and guest operating systems. These are the options for `auto,rw,uid=33,gid=33`: auto: Automatically mount the file system at boot. rw: Mount the file system with read and write permissions. uid=33: Set the user ID of the mounted files to 33 (usually the www-data user in many Linux distributions). Change this to your Apache's user. gid=33: Set the group ID of the mounted files to 33 (usually the www-data group in many Linux distributions). Change this to your Apache's group. 6.2) Configuring Apache From your Apache configuration (`/etc/apache2/sites-available/000-default.conf`): <VirtualHost *:80> ServerName pw.test ServerAdmin webmaster@localhost DocumentRoot /var/www/pw.test LogLevel debug ErrorLog ${APACHE_LOG_DIR}/error.log CustomLog ${APACHE_LOG_DIR}/access.log combined </VirtualHost> Here, the `DocumentRoot` points to what you have in your fstab. 6.3) Configuring MySQL/MariaDB From your MySQL/MariaDB configuration (`/etc/mysql/mariadb.conf.d/50-server.cnf`): [mysqld] bind-address = 0.0.0.0 #skip-external-locking 7) Connecting to the Database This is how I connect to my database via a database manager. The important information here is the Host and Port. ? Accessing the Web Server In your browser, type `http://localhost:8080`. That's it! ========== Let me know your thoughts and suggestions. Cheers!1 point