Associate Project Guide PDF
Associate Project Guide PDF
Study Project
Build an order export tool to push data into a custom ERP
Joseph Maxwell
The difference between the study guide and this project guide is:
● The study guide covers every topic on the test, in-depth.
● This guide walks you through creating a project.
● The examples in the study guide are not related, but focused on the specific
discussion for that topic.
● The example in this guide is one cohesive unit, but doesn’t have as much in-depth
information for each subject.
Of course, I would suggest that these are used in tandem. That is up to you.
The goal of this project guide is this: I wish I had the time to sit down with you and help you
build this module. Alas, but that is not a possibility. So this is the next-best thing.
I love to teach and share the knowledge that others have so kindly helped me out with. I
pass on that knowledge here.
I estimate that this study project covers over 70% of the knowledge required to pass the
test. There are a few areas that are not covered at this moment:
● 1.6: Configure event observers and scheduled [cron] jobs. I recommend that you add
an event observer to this project and follow the path of how this works.
● 3.3: We do not utilize block types other than the default template. A thorough
review of this would be beneficial.
● 5.4: Set up a menu item.
● 6: Product types, categories, shipments and customers. A review of these sections in
the study guide is imperative.
Oh, and if you have found this helpful, I am pulling together videos for all of this material on
my YouTube channel.
If you have suggestions or critiques, I am all ears. I want this to be an excellent and valuable
resource for years to come. Please email me: [email protected]
Yours truly,
Joseph Maxwell
Requirements
Environment configuration
Implementation details
Step 6: Update the database to provide details as to when the order was exported
Overview
Step 6.1: Create db_schema.xml
Step 6.2: Generate the model
● Ability to specify a SKU override as a product attribute. This will allow us to use one
SKU on the website, and another will be sent to the ERP.
● Create a user interface on the view order page where a CSR can configure order
details and push to the ERP:
○ Requested shipping date
○ Merchant notes field
○ Button to submit
● Only simple and virtual products are sent (including simple or virtual child products
of a parent bundle, grouped or configurable)
○ SKU
○ QTY
○ Price per item
○ Cost per item
{
"password": 1234,
"id": "1000001",
"currency": "USD",
"shipping": {
"name": "Joseph Maxwell",
"address": "123 Main Street",
"city": "Kansas City",
"state": "KS",
"postcode": "12345",
"country": "US",
"amount": 15,
"method": "UPS",
"ship_on": "17/12/2020"
},
"customer_notes": "Please ship carefully.",
"merchant_notes": "PO #123456",
"items": [
{
Overview
● Data is POSTed to our Magento API via AJAX (a controller, for the purposes of this
study guide as that is what is covered on the test, but ideally, an API).
● Split logic up into smaller files (instead of having one massive logic file).
composer install
Next, set the db-host, db-user and db-password. Remove the amqp array keys/values.
In PHPStorm, go to Run > Configurations. Expand the Templates level and click PHPUnit.
Check “Defined in Configuration File” as well as the “Use alternative configuration file”. Set
the value for the alternative configuration file to be
dev/tests/quick-integration/phpunit.xml.
The first time you run it will take some time to execute.
In this case, we have already detailed what needs to happen and what is the final outcome.
You need to write to make this happen.
Of course, I have already written this code. I suggest that you use my code as a
double-check and not as a
Further reading:
● https://www.khanacademy.org/computing/computer-programming/programming
/good-practices/a/planning-a-programming-project
Later, I highly recommend getting into writing unit and integration tests as the starting
point. This has many advantages, the biggest of which is that your code is already tested.
You know when you break it.
In this case, we are going to start with the form on the view order page. We will:
1. Initialize the module (Step #1).
2. Add layout XML instructions, create a .phtml template, set up a view model and
link a .js file to power up the display on the View Order page (step #2).
3. Create the admin controller which will trigger the necessary processes (step #3).
6. Update the database to provide details as to when the order was exported (step #6).
I hope you can see the chain of logic. Each step builds upon the previous step. Each step
provides a stopping point at which you can look back and see the work you have
completed.
● app/code/SwiftOtter/OrderExport/registration.php
● app/code/SwiftOtter/OrderExport/composer.json
● app/code/SwiftOtter/OrderExport/etc/module.xml
We get the layout handle from either the URL or Body Tag:
● URL: https://lc.associate.site/admin_dev/sales/order/view/order_id/3/key/.../
(note that this is my local environment’s URL and your’s may vary).
● Body tag: sales-order-view page-layout-admin-2columns-left
Please note that in the admin panel, this could be a uiComponent. In this instance, it is not,
but this is a possibility for customizing forms and grids.
First, look through Store Configuration to find a relevant section. In this case, we will
use the Sales (tab) > Sales (section). We will create our own group for this module.
● Now, search through the vendor/magento directory for all instances of Sales,
matching case and filtering by file *system.xml. Since we know this is
sales-related, I suggest looking for references in the
vendor/magento/module-sales directory (or even starting your search there).
<section id="sales"
Go back to
vendor/magento/module-sales/etc/adminhtml/system.xml, find a
group, and copy it’s values. Update them as applicable. Look for an
app/code/SwiftOtter/OrderExport/view/adminhtml/layout/sales_order_vie
w.xml
I suggest using the ifconfig layout XML attribute to determine whether or not to display
this block.
Note: the best Magento user experience would be adding a button to the top order menu
bar that triggers a slideout panel. However, for the sake of this example and the topics
covered by the Associate Developer exam, we are using .phtml templates and layout XML.
How do I know what container I should reference for this block? Start by locating the
original sales_order_view.xml file.
This will provides the option to set a shipping date and shipping notes. It also has a button
to export the order.
Feel free to copy the .phtml file to your own project. Frankly, writing HTML is not overly
pertinent to the test. I do suggest you take time to understand how the template interacts
with the view model.
Step 2.6: Create a view model to provide business logic for this
template.
SwiftOtter\OrderExport\ViewModel\OrderDetails
app/code/SwiftOtter/OrderExport/ViewModel/OrderDetails.php
Now, clear the cache and visit an order page. You should see your content from the .phtml
file now present.
app/code/SwiftOtter/OrderExport/view/adminhtml/web/js/upload-form.js
Note: this should use uiComponents and Knockout templates (.html). I am reducing this
example a little to provide a practical demonstration of what the test expects.
Feel free to copy this Javascript file into your project. Please review this file to understand
what it is doing.
Now submit an order export. Of course it won’t return information as the controller doesn’t
even exist yet. However, you just proved that you have triggered a successful HTTP
request.
The first step in this process is to tell Magento that our module can listen to web requests
(Web API is different).
app/code/SwiftOtter/OrderExport/etc/adminhtml/routes.xml
SwiftOtter\ExportOrders\Controller\Adminhtml\Export\Run
app/code/SwiftOtter/OrderExport/Controller/Adminhtml/Export/Run.php
https://your.test.site/admin_dev/order_export/export/run (sample
domain)
How do we know what to put into a controller? Look for another admin controller in the
Magento source code. We have been working with the sales_order_view controller, so
let’s go find that and use it as a loose template for our new controller.
vendor/magento/module-sales/Controller/Adminhtml/Order/View.php
Alas, but this class extends another class in the Magento_Sales module. Let’s navigate to
that one. Here, we find the class we should extend in our controller:
Magento\Backend\App\Action.
I also recommend having your controller implement the appropriate interface for the type
of request this controller expects. In this case, it is:
Magento\Framework\App\Action\HttpPostActionInterface
You can also create a test implementation that returns the JSON result to ensure that
everything is working at this point.
Step 3.3: Determine the URL to trigger this route and add it to the
view model.
(reference 2.3)
// in app/code/SwiftOtter/OrderExport/ViewModel/OrderDetails.php
return [
// ...
'upload_url' =>
$this->urlBuilder->getUrl('order_export/export/run')
];
There is one major piece of functionality that we are missing. The order ID. Let’s add a
parameter to this getUrl call:
$this->urlBuilder->getUrl(
'order_export/export/run',
[
'order_id' => (int)$this->request->getParam('order_id')
By casting the value to an integer, we are reducing (maybe eliminating) the possibility for
malicious Javascript code being written into the frontend.
We need to create a class that represents a structured way to pass the ship date and
merchant notes to our JSON transformer action. Of course, there is nothing wrong with two
strings. I prefer a class just for this purpose as we can type-hint and type-check all in one.
This model doesn’t have to extend or inherit any class or interface. It will only have getters
and setters.
Once you create the two private variables, you can set your pointer on one of the
variables and press Cmd+N (Mac) or Ctrl+N (Windows) and then click Getters and Setters.
This will automatically generate getter and setter methods for the desired properties.
SwiftOtter\OrderExport\Model\HeaderDataFactory
I suggest that you set a breakpoint in the controller’s execute method. Then, click the
“Send Order to Fulfillment” button on the frontend and let’s see what happens!
Overview
We need to take data stored in the sales_order table and sales_order_item table and
transform it into a PHP array (that will be converted to JSON in the next step).
Of course, we could do this in one class with many public methods. But my goal in this book
is to demonstrate best practice. Ideally, we will have one public method per class with a
number of supporting private methods. This reduces the number of dependencies per class
making it easier to debug and test.
These collector classes are associated with the iterator class in etc/di.xml.
Utilize the Magento CLI to create a data patch. If you don’t remember the command to
create the path, run bin/magento and look for the item having patch in its name (or,
better yet bin/magento | grep patch).
bin/magento setup:db-declaration:generate-patch
SwiftOtter_OrderExport CreateSkuOverrideAttribute
SwiftOtter\OrderExport\Setup\Patch\Data\CreateSkuOverrideAttribute
There are quite a few ancillary comments in this file and I like to clean those out.
How do we know the syntax to create a new product attribute? Again, we turn to either
the internet or Magento for an example. Magento is ideal. Let’s go hunt for a new attribute.
As we think about it, the catalog module is responsible for products. There are many
Let’s look through some other modules that would be related to products. The
module-bundle-product module doesn’t have any data patches that create an attribute.
The module-configurable-product doesn’t either.
We are striking out. The next thing is to think with some common sense: what might the
method name be to add an attribute (we can supplement with the internet, too)? How
about addAttribute? Let’s do a search for addAttribute(.
Now, change the appropriate values (like the attribute code, name, and what product types
it applies to, type and scope).
Further reading:
● Magento 2: what is the catalog_attributes.xml file?
● How to access custom catalog attributes in Magento 2
To create this file, do a search in your Magento source code for a file with the name
catalog_attributes.xml (in PHPStorm, press Shift twice). Create a similar file in your
module and add your attribute under the catalog_product group.
This method clearly represents the entry point to this class. As an action, other developers
immediately identify this file that it does something.
Remember, there is nothing magical about this class or its naming. We are simply creating a
self-contained unit of functionality. Any other area of the application could, in theory, easily
utilize this code provided it passes in the correct data requirements.
As you will see in the above execute method, we take an Order ID and a HeaderData
class. We now need to load up the order. We could use a repository or collection. Since an
In our execute method, we load the order and then iterate through each transformer. If we
type-hinted our $transformers class property with the interface (in my case,
SwiftOtter\OrderExport\Api\DataCollectorInterface), we will get code
completion.
Before we continue, let’s go ahead and create an entry in di.xml that will eventually
contain our list of collectors:
xsi:noNamespaceSchemaLocation="urn:magento:framework:ObjectManager/et
c/config.xsd">
<type name="SwiftOtter\OrderExport\Action\TransformOrderToArray">
<arguments>
<argument name="collectors" xsi:type="array">
<!-- collectors go here -->
<!-- sample: -->
<!-- <item name="header_data"
xsi:type="object">SwiftOtter\OrderExport\Collector\HeaderData</item>
-->
</argument>
</arguments>
</type>
</config>
Of course, we could add it as a dependency to the controller. That would work fine, except
for the fact that we have some additional steps in this business logic process.
This will become the central entry point to complete this functionality.
Feel free to click our now-famous “Send Order to Fulfillment” button and see how this
works.
Let’s take a first cut at our collectors will look like. Remember, each collector will have a
specific subset of order data to collect and return to our parent iterator.
Note: why would we create this interface first? Isn’t that out of order? Yes, it kind of it. I am
doing it this way to provide
A collector should collect. One public method. Of course, we can have other private
methods.
Let’s think through what parameters are needed. First, each collector needs an order. We
could get specific and say that the Simple product collector should be sent an array of the
order items. Or, better yet, we let the Simple product collector load the order items. The
possibility for redundancy isn’t a problem as:
● order items are fast to load (unlike EAV)
● this type of operation, executed by an admin, doesn’t have to be as performant as
the frontend.
We also have the header data collector that loads the shipping address and totals. For the
sake of standardization, we could pass this class to each collector.
I find that interfaces are refined over time. When you write an interface, you will probably
come back later to change it.
Our first of several data collectors will be the one to gather up the “header” data or
anything that is not item data.
{
"password": 1234,
"id": "1000001",
"currency": "USD",
"shipping": {
"name": "Joseph Maxwell",
"address": "123 Main Street",
"city": "Kansas City",
"state": "KS",
"postcode": "12345",
"country": "US",
"amount": 15,
"method": "UPS",
"ship_on": "17/12/2020"
},
"customer_notes": "Please ship carefully.",
"merchant_notes": "PO #123456",
"discount": 0,
"total": 49
}
This is where a senior developer or a tech lead whom you can ask questions of is
invaluable.
The challenge here is that searching the vendor/magento directory for all references to
“password” (in *.system.xml files) would not yield the correct approach, either. This is a
fairly rare occurrence and will be solved through additional experience. You are reading
this and shouldn’t have to work through this challenge again.
I am putting this in its own section before “Build the header data array” so that we can
delve into a solid way to get the address.
We need the shipping address so we can send back the ship-to name, address, etc. The first
place to look would be on the OrderInterface. In our execute method, let’s use
autocomplete to help:
$order->getAddress
We could bypass the OrderInterface and call getShippingAddress directly from the
Order model implementation. But this would not be future-proof as Magento may remove
that method.
Let’s do this another way: by using the order address repository. In your constructor, start
typing OrderAddress and select
Magento\Sales\Api\OrderAddressRepositoryInterface. If we are needing to filter
results, we will also need to inject a SearchCriteriaBuilder.
I suggest using autocomplete to help out here. Start typing $order->ship. This in itself
will not yield a correct method. Yet, the autocomplete should guide us to better answer. We
could call the $order->getShippingAddress() method on the Order implementation of
OrderInterface. However, we are in this for our personal development, so let’s take the
opportunity to load up the shipping address in another way.
● password: note that we pass the scopeType argument as SCOPE_STORES and set
the store ID. When this code is executed, we will be in the admin store scope.
Because the password can be changed in global, website or store view scopes, we
● currency: the order’s base or order currency code can be obtained. Remember,
the base currency code is the code for the website (or global scope, if the Product
Price Scope = Global). The order currency code is the code in which the order was
placed (from the list of allowed currencies in Stores > Configuration > General >
Currency Setup).
● shipping: see discussion above. This is optional—if an order only has virtual
products, then there is no shipping address.
● customer_notes: this comes from the Bold Commerce customer notes module.
While we could look in that module’s code, the first place I like to look at is in
extension attributes: $order->getExtensionAttributes()
● Totals: in this case, I suggest working with the getBase... values. This means the
values that are transported to the ERP will be the original currency values, and not
those that are converted.
Finally, we must add this HeaderData collector to the list of collectors, using di.xml.
app/code/SwiftOtter/OrderExport/Collector/ItemData.php
SwiftOtter\OrderExport\Collector\ItemData
We need to assemble this list of items and return it to the master array.
How do we get a list of these items? First, turn to the OrderInterface object. Are there
any methods that have to do with getItems? It turns out that our answer is “yes”. If we
navigate to the Order class (see here), we will find that the getItems method will initialize
itself if no items exist.
We forgot something that is very important. We need to ensure that only the correct
product types (simple and virtual) are exported.
<type name="SwiftOtter\OrderExport\Collector\ItemData">
<arguments>
<argument name="allowedTypes" xsi:type="array">
<item name="simple"
xsi:type="const">Magento\Catalog\Model\Product\Type::DEFAULT_TYPE</it
em>
<item name="virtual"
Why would we do it this way? Couldn’t we use a constant in our class? Yes, of course. But
this way provides a good deal of flexibility:
● Other developers can easily extend.
● Our unit tests can be flexible.
● We can have different values for the frontend and adminhtml scopes.
If you don’t need any of the above, there is no problem with putting this as a constant in the
class. Remember, we are working to hone our “developer” capabilities and going above and
beyond is the #1 way to do this.
Note: if you are using integration tests and find that you add this code, but then run the test
and get an error like:
That is because the integration test environment’s cache needs to be cleared. Delete the
folder dev/tests/integration/tmp/sandbox-[numbers here]/var/cache.
Let’s create a new private method that will transform the items as they are passed
through. Of course, we could do this in the foreach in the previous section. But, we should
work to create methods that are 15 lines or less in length. That makes it easier to read.
app/code/SwiftOtter/OrderExport/Test/Integration/Collector/ItemDataTe
st.php
SwiftOtter\OrderExport\Test\Integration\Collector\ItemDataTest
app/code/SwiftOtter/OrderExport/Action/PushDetailsToWebservice.php
SwiftOtter\OrderExport\Action\PushDetailsToWebservice
For purposes of this course, this file is quite empty. However, if you would like to push to
an external resource, this is where you would do it.
Before we close off this section, you might also notice that the return type for execute is
bool. A true or false returns very little information about errors that occur. We could
change the return type out to a string. But that also doesn’t work very well, as how do we
know that this action successfully completed? A true is very clear that this worked well.
Instead, throwing exceptions are very effective. You can catch them to learn what
happened in the method. Left uncaught, they will stop the process of the application, which
might not be a bad thing. This is all under your control.
When you are having trouble figuring out how to alert calling methods of a problem in a
method, use an exception.
In this step, we save details to the database about the order export process.
Overview
There are quite a few steps to this one, so please bear with me.
When I was learning Magento, this was a difficult question for me to answer. Since they are
attached to an order, what about adding columns to the sales_order table? But, there
could be a possibility that we would want multiple rows per order in the future. Creating a
new table can seem like a big task, but thanks to db_schema.xml, it is much easier than
you would think.
We can start by populating this file’s skeleton with our PHPStorm template.
Note: you can utilize some XML auto-completion with this command (for PHPStorm
projects):
● id: is an identity. Note that you also need a primary constraint to make this column
a primary key.
● ship_on: date type. We don’t need the time as that is not available in the selector in
the admin panel.
Extends: Magento\Framework\Model\AbstractModel
For each member of the model triad (model, resource model and collection), there is a
Magento class that we should extend. The challenge is remembering which class that is.
If I ever forget, I often turn to the Magento CMS module. The Block model is a good and
basic pattern for your classes.
Configure parameters
You need to override the _construct() method (one underscore). Here, you will assign
the class path to your resource model.
This file has not yet been created, but we can anticipate the page:
$this->_init(\SwiftOtter\OrderExport\Model\ResourceModel\OrderExportD
etails::class);
If we extend the AbstractModel class, which we should, data will be loaded into this
model’s $data array.
If you plan to expose this model via the API, then “yes”. Otherwise, don’t worry about it.
For the sake of our learning today, we will go ahead with creating the interface.
app/code/SwiftOtter/OrderExport/Api/Data/OrderExportDetailsInterface.
php
SwiftOtter\OrderExport\Api\Data\OrderExportDetailsInterface
The fastest and most accurate way that I have found to create an interface from a class is:
● Copy the methods from the model.
● Paste them into the interface.
● Delete out everything between the {} around a method and replace it with a ;.
Resource model
app/code/SwiftOtter/OrderExport/Model/ResourceModel/OrderExportDetail
s.php
SwiftOtter\OrderExport\Model\ResourceModel\OrderExportDetails
Extends: \Magento\Framework\Model\ResourceModel\Db\AbstractDb
This file bears the same name as the model—with the only difference is that the resource
model is stored in the module’s Model/ResourceModel directory (by convention).
We need to implement the _construct method and call the _init method to specify the
table in the database and the column that is the row identifier (primary key).
Collection
app/code/SwiftOtter/OrderExport/Model/ResourceModel/OrderExportDetail
s/Collection.php
SwiftOtter\OrderExport\Model\ResourceModel\OrderExportDetails\Collect
ion
Extends:
\Magento\Framework\Model\ResourceModel\Db\Collection\AbstractCollecti
on
Notice that this file is almost always named “Collection”, but it is inside the
Model/ResourceModel/[model name] directory.
The _construct method (again, one underscore) should call the _init method with the
model class and the resource model class, respectively.
SwiftOtter\OrderExport\Model\OrderExportDetails
OrderExportDetails is the model name. Model would be the last “directory” in the
namespace path:
This works especially well if we need to import two classes with the same name (but, of
course, different namespace paths).
At this point, we don’t have much new tech that can run. But we will get there shortly.
In many ways, a repository is the centralized gateway to a model, resource model, and
collection.
Let’s review each constructor parameter in the BlockRepository class and see if we need
to utilize this on ours:
● $resource: YES. This is the resource model that we just created. This will be used
to save and delete rows in the database.
● $blockFactory: YES (but change the name). This is how we will create a new
instance of our OrderExportDetails model.
● $dataBlockFactory: NO. We do not need this because we do not separate out the
block from its data layer.
● $blockCollectionFactory: YES (but change the name). Remember that
collections store their filters and results. As such, we always want a new clean
collection so we need to use a factory.
● $searchResultsFactory: YES. We will need to create a search results interface for
this repository. Follow the example set in
Magento\Cms\Api\Data\BlockSearchResultsInterface. Don’t forget to
search for BlockSearchResultsInterface in the
vendor/magento/module-cms, filtered by di.xml to find any references to this
class (hint: there is a preference).
● $dataObjectHelper: NO. The block repository itself no longer uses this.
● $dataObjectProcessor: NO. The block repository itself no longer uses this.
● $storeManager: NO. We do not need to know the current store ID.
● $collectionProcessor: YES. This is used by the getList method to convert a
SearchCriteriaInterface to methods utilized in a collection.
Ensure dependency injection is configured. The easiest way to replicate what Magento has
done for the block repository is to search for BlockRepository in the vendor/magento
directory, filtered by files that match *di.xml.
Create methods
We can use the BlockRepository, again, as a template for how we should build the
OrderExportDetails repository.
● save: this is a centralized place to save a model (that we create). Remember that
there is nothing magical about a repository. There is also no central implementation
and all methods created here are by convention.
● getById: this loads a model by it’s ID. Please do not use the load method that is
still available on a model. That is deprecated and will go away at some point in the
future.
● getList: this is like a collection. Arguably it’s a little more convenient as we don’t
have to create a factory for this repository.
Thus far, Step 6 has been boring. We are about to change this.
app/code/SwiftOtter/OrderExport/etc/extension_attributes.xml
Please take a moment to review in the study guide and on Magento DevDocs what is an
extension attribute.
Once you have declared your extension attributes, please delete the
generated/Magento/Sales directory. This will force the generated extension attribute
interfaces and classes to be regenerated.
The interesting part of extension attributes is that they are a shell. They do nothing by
themselves. We make them happen in their entirety.
app/code/SwiftOtter/OrderExport/Plugin/LoadExportDetailsIntoOrder.php
SwiftOtter\OrderExport\Plugin\LoadExportDetailsIntoOrder
Don’t forget to return the value from the after methods (for that matter, before or
around plugins, too).
And, please remember to add the type and plugin nodes to the module’s etc/di.xml file.
In addition, there is no automated way to set your specific extension attribute’s value. You
must do that.
How do I know which extension attribute factory to inject? This takes a little work as
the parent extension attribute class (what is returned from calling
getExtensionAttributes on a extension-attribute-enabled class) is generated.
Magento\Sales\Api\Data\OrderExtensionInterfaceFactory
Note the addition of Extension and Factory. Extension is always added before
Interface and Factory just after Interface.
Would yield:
Magento\Catalog\Api\Data\ProductExtensionInterfaceFactory
Feel free to set breakpoints in our LoadExportDetailsIntoOrder class and visit the
order page.
At this point, I hope you take a moment to stand back and admire how good the code is
looking.
Overview
This is all pretty straightforward. We are mapping the merchant notes and ship on date to
the applicable values in the details object. If the export was successful, we set the export
date. Then we save the export detail model.
Again, we are just using a controller for the sake of learning about controllers. The far
better route is to use a Web API request. The way we have configured this module means a
very simple conversion to use the WebAPI.
Indicate on the admin order view that this order has been exported.
This class should be very familiar as we use concepts that you should already know. We
take the order_id value from the RequestInterface and use that to load up the order
from the OrderRepository.
This template needs to do nothing special: its only purpose is to show the status of whether
or not the order has been exported.
I am trying to demonstrate that it is very easy to use multiple templates. There is no need
to stuff everything into one template.
We are creating a block (no block type specified as it infers the Template type) with the
view model argument. This should be EASY by now.
● Magento StackExchange: answers from users with 2k reputation and more are
usually acceptable. Still, hold even these answers with a grain of salt.
● Alan Storm
● Atwix
● Mage2.pro